wahnsinn vibe

This commit is contained in:
Marek Lenczewski
2026-04-16 19:42:06 +02:00
parent 9c5da44f64
commit e3e88cc58e
127 changed files with 9456 additions and 3 deletions

12
frontend/admin/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "@shop/admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@shop/shared": "workspace:*",
"@vueuse/core": "^11.1.0",
"axios": "^1.7.7",
"pinia": "^2.2.4",
"vue": "^3.5.11",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vue-tsc": "^2.1.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import HealthBadge from "./components/HealthBadge.vue";
import { useAuth } from "./stores/auth";
const auth = useAuth();
const route = useRoute();
const router = useRouter();
onMounted(async () => {
await auth.fetchMe();
if (!auth.isAdmin && route.path !== "/login") {
router.push("/login");
}
});
function logout() {
auth.logout();
router.push("/login");
}
</script>
<template>
<div v-if="route.path === '/login'" class="min-h-screen flex items-center justify-center">
<RouterView />
</div>
<div v-else class="min-h-screen flex">
<aside class="w-56 bg-admin-700 text-white flex flex-col">
<div class="p-4 text-lg font-bold border-b border-admin-600">Shop Admin</div>
<nav class="flex-1 p-2 space-y-1 text-sm">
<RouterLink to="/" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
🏠 Dashboard
</RouterLink>
<RouterLink to="/products" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
📦 Produkte
</RouterLink>
<RouterLink to="/categories" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
🏷 Kategorien
</RouterLink>
<RouterLink to="/orders" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
🛒 Bestellungen
</RouterLink>
<RouterLink to="/settings" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
Einstellungen
</RouterLink>
</nav>
<div class="p-3 border-t border-admin-600 text-xs">
<div class="mb-1">{{ auth.user?.email }}</div>
<button @click="logout" class="underline">Abmelden</button>
</div>
</aside>
<main class="flex-1 overflow-auto">
<div class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
<div class="text-sm text-gray-500">Admin-Panel</div>
<HealthBadge />
</div>
<div class="p-6">
<RouterView />
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,2 @@
import { createApi } from "@shop/shared/api";
export const api = createApi("");

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { ProposalCard as ProposalCardType } from "@shop/shared/types";
import { computed, ref } from "vue";
import { api } from "../api";
import ProposalCard from "./ProposalCard.vue";
interface CardState {
card: ProposalCardType;
state: "pending" | "confirmed" | "rejected" | "executing" | "success" | "error";
result?: any;
error?: string;
}
const prompt = ref("");
const planning = ref(false);
const planError = ref("");
const cards = ref<CardState[]>([]);
async function plan() {
if (!prompt.value.trim()) return;
planning.value = true;
planError.value = "";
try {
const r = await api.post("/api/ai_admin/plan", { prompt: prompt.value });
cards.value = (r.data.cards || []).map((c: ProposalCardType) => ({ card: c, state: "pending" }));
if (!cards.value.length) planError.value = "Die KI konnte keinen Aktionsplan erzeugen.";
} catch (e: any) {
planError.value = e.response?.data?.detail || e.message;
} finally {
planning.value = false;
}
}
function updateArgs(i: number, args: Record<string, any>) {
cards.value[i].card.args = args;
// remove from 'missing' those that now have values
cards.value[i].card.missing = (cards.value[i].card.missing || []).filter(
(k) => args[k] === undefined || args[k] === null || args[k] === ""
);
}
async function execute(i: number) {
const cs = cards.value[i];
if (cs.card.missing.length) {
cs.state = "error";
cs.error = `Fehlende Felder: ${cs.card.missing.join(", ")}`;
return;
}
cs.state = "executing";
try {
const r = await api.post("/api/ai_admin/execute", {
cards: [{ tool: cs.card.tool, args: cs.card.args }],
});
const res = r.data.results[0];
if (res.ok) {
cs.state = "success";
cs.result = res.result;
} else {
cs.state = "error";
cs.error = res.error;
}
} catch (e: any) {
cs.state = "error";
cs.error = e.response?.data?.detail || e.message;
}
}
function reject(i: number) {
cards.value[i].state = "rejected";
}
async function confirmAll() {
for (let i = 0; i < cards.value.length; i++) {
if (cards.value[i].state === "pending") {
await execute(i);
}
}
}
function reset() {
cards.value = [];
prompt.value = "";
planError.value = "";
}
const pendingCount = computed(() => cards.value.filter((c) => c.state === "pending").length);
</script>
<template>
<div class="card">
<h2 class="text-lg font-semibold mb-2">🤖 KI-Assistent</h2>
<p class="text-sm text-gray-600 mb-3">
Sag, was getan werden soll. Die KI erzeugt nur Vorschläge ausgeführt wird erst nach
deiner Bestätigung. Du kannst auch JSON-Daten reinwerfen.
</p>
<div class="flex gap-2">
<textarea
v-model="prompt"
rows="3"
class="input font-mono text-sm"
placeholder="z.B. 'setze den Shopnamen auf TEST123' oder [{sku:'NEW-1',...}] erstelle diese"
/>
<div class="flex flex-col gap-2">
<button @click="plan" :disabled="planning" class="btn-primary">
{{ planning ? "Plane..." : "Planen" }}
</button>
<button v-if="cards.length" @click="reset" class="btn-secondary">Reset</button>
</div>
</div>
<div v-if="planning" class="text-xs text-gray-500 mt-2">
Lokales LLM rechnet. Bei Bulk-Operationen über viele Items kann das ein paar Minuten dauern.
</div>
<div v-if="planError" class="text-red-600 text-sm mt-2">{{ planError }}</div>
<div v-if="cards.length" class="mt-4">
<div class="flex items-center justify-between mb-3">
<div class="font-medium">{{ cards.length }} Vorschlag(e)</div>
<button v-if="pendingCount > 1" @click="confirmAll" class="btn-success text-sm">
Alle bestätigen ({{ pendingCount }})
</button>
</div>
<div class="space-y-3">
<ProposalCard
v-for="(cs, i) in cards"
:key="i"
:card="cs.card"
:state="cs.state"
:result="cs.result"
:error="cs.error"
@update:args="(a) => updateArgs(i, a)"
@confirm="execute(i)"
@reject="reject(i)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api";
const ok = ref<boolean | null>(null);
const info = ref<any>(null);
async function check() {
try {
const r = await api.get("/health");
ok.value = r.data.ok;
info.value = r.data;
} catch {
ok.value = false;
}
}
onMounted(check);
</script>
<template>
<div class="flex items-center gap-2 text-xs text-gray-600">
<span
class="w-2 h-2 rounded-full"
:class="ok === null ? 'bg-gray-300' : ok ? 'bg-green-500' : 'bg-red-500'"
></span>
<span>
{{ ok === null ? "…" : ok ? `Backend online (${info?.apps?.length} Apps)` : "Backend offline" }}
</span>
</div>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { ProposalCard } from "@shop/shared/types";
import { computed, ref } from "vue";
const props = defineProps<{
card: ProposalCard;
state: "pending" | "confirmed" | "rejected" | "executing" | "success" | "error";
result?: any;
error?: string;
}>();
const emit = defineEmits<{
(e: "update:args", args: Record<string, any>): void;
(e: "confirm"): void;
(e: "reject"): void;
}>();
// local editable copy of args
const localArgs = ref<Record<string, any>>({ ...props.card.args });
function update(key: string, value: any) {
localArgs.value = { ...localArgs.value, [key]: value };
emit("update:args", localArgs.value);
}
const props_ = computed<Record<string, any>>(() => {
return (props.card.schema?.properties as Record<string, any>) || {};
});
const requiredKeys = computed<string[]>(
() => (props.card.schema?.required as string[]) || []
);
// union of schema keys + keys already present in args (so unusual fields show too)
const allKeys = computed<string[]>(() => {
const s = new Set<string>([...Object.keys(props_.value), ...Object.keys(localArgs.value || {})]);
return Array.from(s);
});
function isMissing(k: string): boolean {
return props.card.missing?.includes(k) || false;
}
function inputType(schema: any): string {
const t = schema?.type;
if (t === "number" || t === "integer") return "number";
if (t === "boolean") return "checkbox";
return "text";
}
function stringify(v: any) {
if (v === null || v === undefined) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
</script>
<template>
<div
class="card border-l-4"
:class="{
'border-l-yellow-400': state === 'pending',
'border-l-blue-500': state === 'executing' || state === 'confirmed',
'border-l-green-500': state === 'success',
'border-l-red-500': state === 'error' || state === 'rejected',
}"
>
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500">{{ card.tool }}</div>
<div class="font-medium">{{ card.preview }}</div>
<div v-if="card.notes" class="text-xs text-gray-500 italic mt-1">{{ card.notes }}</div>
</div>
<span
class="text-xs px-2 py-0.5 rounded-full"
:class="{
'bg-yellow-100 text-yellow-700': state === 'pending',
'bg-blue-100 text-blue-700': state === 'executing' || state === 'confirmed',
'bg-green-100 text-green-700': state === 'success',
'bg-red-100 text-red-700': state === 'error' || state === 'rejected',
}"
>{{ state }}</span>
</div>
<div v-if="allKeys.length" class="grid grid-cols-2 gap-2 mt-3">
<div v-for="k in allKeys" :key="k">
<label class="label">
{{ k }}
<span v-if="requiredKeys.includes(k)" class="text-red-500">*</span>
<span
v-if="isMissing(k)"
class="ml-1 text-xs text-red-600 bg-red-50 px-1 rounded"
>fehlt</span>
</label>
<input
v-if="inputType(props_[k]) === 'checkbox'"
type="checkbox"
:checked="!!localArgs[k]"
:disabled="state !== 'pending'"
@change="(e: any) => update(k, e.target.checked)"
/>
<textarea
v-else-if="typeof localArgs[k] === 'object' && localArgs[k] !== null"
class="input font-mono text-xs"
rows="2"
:disabled="state !== 'pending'"
:value="stringify(localArgs[k])"
@input="(e: any) => {
try { update(k, JSON.parse(e.target.value)); } catch {}
}"
/>
<input
v-else
:type="inputType(props_[k])"
:step="props_[k]?.type === 'number' ? 'any' : undefined"
:value="stringify(localArgs[k])"
:disabled="state !== 'pending'"
@input="(e: any) => update(k, props_[k]?.type === 'number' || props_[k]?.type === 'integer' ? Number(e.target.value) : e.target.value)"
class="input"
:class="{ 'border-red-400': isMissing(k) }"
/>
</div>
</div>
<div v-if="state === 'success' && result" class="mt-3 text-sm text-green-700 bg-green-50 p-2 rounded">
Ausgeführt: {{ JSON.stringify(result) }}
</div>
<div v-if="state === 'error' && error" class="mt-3 text-sm text-red-700 bg-red-50 p-2 rounded">
{{ error }}
</div>
<div v-if="state === 'pending'" class="mt-3 flex gap-2">
<button @click="emit('confirm')" class="btn-success">Bestätigen</button>
<button @click="emit('reject')" class="btn-secondary">Verwerfen</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,10 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import { router } from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { Category } from "@shop/shared/types";
import { onMounted, reactive, ref } from "vue";
import { api } from "../api";
const categories = ref<Category[]>([]);
const form = reactive({
slug: "",
name_de: "",
name_en: "",
sort_order: 0,
});
const error = ref("");
async function load() {
const r = await api.get("/api/catalog/admin/categories");
categories.value = r.data;
}
async function create() {
error.value = "";
try {
await api.post("/api/catalog/admin/categories", {
slug: form.slug,
name: { de: form.name_de, en: form.name_en },
sort_order: form.sort_order,
});
form.slug = "";
form.name_de = "";
form.name_en = "";
form.sort_order = 0;
await load();
} catch (e: any) {
error.value = e.response?.data?.detail || e.message;
}
}
async function del(c: Category) {
if (!confirm(`Kategorie ${c.slug} löschen?`)) return;
await api.delete(`/api/catalog/admin/categories/${c.id}`);
await load();
}
onMounted(load);
</script>
<template>
<div>
<h1 class="text-2xl font-bold mb-4">Kategorien</h1>
<div class="grid md:grid-cols-[1fr_320px] gap-6">
<div class="card">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Slug</th>
<th>Name (DE)</th>
<th>Sortierung</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in categories" :key="c.id">
<td>{{ c.id }}</td>
<td class="font-mono text-xs">{{ c.slug }}</td>
<td>{{ c.name.de }}</td>
<td>{{ c.sort_order }}</td>
<td class="text-right">
<button @click="del(c)" class="text-red-500 text-xs">Löschen</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2 class="font-semibold mb-3">Neue Kategorie</h2>
<form @submit.prevent="create" class="space-y-2">
<div>
<label class="label">Slug</label>
<input v-model="form.slug" class="input" required />
</div>
<div>
<label class="label">Name (DE)</label>
<input v-model="form.name_de" class="input" required />
</div>
<div>
<label class="label">Name (EN)</label>
<input v-model="form.name_en" class="input" />
</div>
<div>
<label class="label">Sortierung</label>
<input v-model.number="form.sort_order" type="number" class="input" />
</div>
<button class="btn-primary w-full">Erstellen</button>
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
</form>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api";
import AIChatBox from "../components/AIChatBox.vue";
const stats = ref({ products: 0, categories: 0, orders: 0 });
onMounted(async () => {
try {
const [p, c, o] = await Promise.all([
api.get("/api/catalog/admin/products"),
api.get("/api/catalog/admin/categories"),
api.get("/api/orders/admin"),
]);
stats.value = {
products: p.data.length,
categories: c.data.length,
orders: o.data.length,
};
} catch {
// ignore
}
});
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Dashboard</h1>
<div class="grid grid-cols-3 gap-4">
<div class="card">
<div class="text-sm text-gray-500">Produkte</div>
<div class="text-3xl font-bold">{{ stats.products }}</div>
</div>
<div class="card">
<div class="text-sm text-gray-500">Kategorien</div>
<div class="text-3xl font-bold">{{ stats.categories }}</div>
</div>
<div class="card">
<div class="text-sm text-gray-500">Bestellungen</div>
<div class="text-3xl font-bold">{{ stats.orders }}</div>
</div>
</div>
<AIChatBox />
<div class="card">
<h2 class="font-semibold mb-2">Beispiele für die KI-Box</h2>
<ul class="list-disc pl-5 space-y-1 text-sm text-gray-700">
<li>
<code class="bg-gray-100 px-1">setze den Shopnamen auf TEST123</code>
</li>
<li>
<code class="bg-gray-100 px-1">ändere den Preis von Produkt 1 auf 44.90</code>
</li>
<li>
JSON-Bulk:
<pre class="bg-gray-50 p-2 rounded mt-1 text-xs overflow-x-auto">Das sind neue Produkte, erstelle sie:
[
{"sku":"NEW-TS-RED","name_de":"Rotes T-Shirt","price":18.90,"stock":20},
{"sku":"NEW-SOCK","name_de":"Socken","price":4.90,"stock":100},
{"sku":"NEW-CAP","name_de":"Grüne Kappe","price":12.00,"stock":30}
]</pre>
</li>
</ul>
</div>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuth } from "../stores/auth";
const auth = useAuth();
const router = useRouter();
const email = ref("admin@example.com");
const password = ref("admin123");
async function submit() {
try {
await auth.login(email.value, password.value);
router.push("/");
} catch {
// message surfaced via auth.error
}
}
</script>
<template>
<div class="card w-96">
<h1 class="text-2xl font-bold mb-1">Admin-Login</h1>
<p class="text-sm text-gray-500 mb-4">Nur für Administratoren.</p>
<form @submit.prevent="submit" class="space-y-3">
<input v-model="email" type="email" placeholder="E-Mail" class="input" />
<input v-model="password" type="password" placeholder="Passwort" class="input" />
<button class="btn-primary w-full" :disabled="auth.loading">
{{ auth.loading ? "..." : "Anmelden" }}
</button>
<div v-if="auth.error" class="text-red-600 text-sm">{{ auth.error }}</div>
</form>
<p class="text-xs text-gray-400 mt-3">Demo: admin@example.com / admin123</p>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { i18n } from "@shop/shared";
import type { Order } from "@shop/shared/types";
import { onMounted, ref } from "vue";
import { api } from "../api";
const props = defineProps<{ id: string }>();
const order = ref<Order | null>(null);
const newStatus = ref("");
const note = ref("");
const msg = ref("");
async function load() {
const r = await api.get(`/api/orders/admin/${props.id}`);
order.value = r.data;
newStatus.value = r.data.status;
}
async function updateStatus() {
const r = await api.put(`/api/orders/admin/${props.id}/status`, {
status: newStatus.value,
note: note.value,
});
order.value = r.data;
msg.value = "✓ Status aktualisiert";
note.value = "";
setTimeout(() => (msg.value = ""), 2000);
}
onMounted(load);
</script>
<template>
<div v-if="order" class="max-w-3xl">
<h1 class="text-2xl font-bold mb-2">Bestellung #{{ order.id }}</h1>
<div class="text-sm text-gray-500 mb-4">
{{ new Date(order.created_at).toLocaleString() }}
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="card">
<h2 class="font-semibold mb-2">Artikel</h2>
<div v-for="it in order.items" :key="it.product_id" class="flex justify-between py-1">
<span>{{ i18n.pickI18n(it.name) }} × {{ it.qty }}</span>
<span>{{ it.line_total.toFixed(2) }} </span>
</div>
<hr class="my-2" />
<div class="flex justify-between font-bold">
<span>Gesamt</span>
<span>{{ order.total.toFixed(2) }} {{ order.currency }}</span>
</div>
</div>
<div class="card">
<h2 class="font-semibold mb-2">Adresse</h2>
<div class="text-sm">
{{ order.address.name }}<br />
{{ order.address.street }}<br />
{{ order.address.zip }} {{ order.address.city }}<br />
{{ order.address.country }}
</div>
<h2 class="font-semibold mt-4 mb-2">Zahlung</h2>
<div class="text-sm text-gray-600">
{{ order.payment.method || "dummy" }} Ref {{ order.payment.transaction_id }}
</div>
</div>
<div class="card md:col-span-2">
<h2 class="font-semibold mb-2">Status ändern</h2>
<div class="flex gap-2 items-end">
<div class="flex-1">
<label class="label">Neuer Status</label>
<select v-model="newStatus" class="input">
<option value="paid">paid</option>
<option value="packed">packed</option>
<option value="shipped">shipped</option>
<option value="delivered">delivered</option>
<option value="cancelled">cancelled</option>
</select>
</div>
<div class="flex-1">
<label class="label">Notiz</label>
<input v-model="note" class="input" />
</div>
<button @click="updateStatus" class="btn-primary">Übernehmen</button>
</div>
<div v-if="msg" class="text-green-600 text-sm mt-2">{{ msg }}</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { Order } from "@shop/shared/types";
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { api } from "../api";
const orders = ref<Order[]>([]);
async function load() {
const r = await api.get("/api/orders/admin");
orders.value = r.data;
}
onMounted(load);
</script>
<template>
<div>
<h1 class="text-2xl font-bold mb-4">Bestellungen ({{ orders.length }})</h1>
<div class="card">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Datum</th>
<th>Kunde</th>
<th>Status</th>
<th>Gesamt</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="o in orders" :key="o.id">
<td>#{{ o.id }}</td>
<td>{{ new Date(o.created_at).toLocaleString() }}</td>
<td>{{ o.address?.name || "—" }}</td>
<td>
<span
class="inline-block px-2 py-0.5 rounded-full text-xs"
:class="o.status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
>{{ o.status }}</span>
</td>
<td class="font-semibold">{{ o.total.toFixed(2) }} {{ o.currency }}</td>
<td class="text-right">
<RouterLink :to="`/orders/${o.id}`" class="text-admin-500 text-xs">Detail</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import type { Category, Product } from "@shop/shared/types";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api";
const props = defineProps<{ id?: string }>();
const router = useRouter();
const form = ref<Partial<Product>>({
sku: "",
name: { de: "", en: "" },
description: { de: "", en: "" },
price: 0,
currency: "EUR",
stock: 0,
active: true,
image_url: "",
category_id: null,
attributes: {},
});
const categories = ref<Category[]>([]);
const msg = ref("");
const error = ref("");
async function loadCategories() {
const r = await api.get("/api/catalog/admin/categories");
categories.value = r.data;
}
async function loadProduct() {
if (!props.id) return;
const r = await api.get(`/api/catalog/admin/products`);
const p = r.data.find((x: Product) => String(x.id) === props.id);
if (p) form.value = p;
}
async function save() {
msg.value = "";
error.value = "";
try {
const body = {
sku: form.value.sku,
name: form.value.name,
description: form.value.description,
price: Number(form.value.price),
currency: form.value.currency,
stock: Number(form.value.stock),
active: form.value.active,
image_url: form.value.image_url,
category_id: form.value.category_id,
attributes: form.value.attributes,
};
if (props.id) {
await api.put(`/api/catalog/admin/products/${props.id}`, body);
} else {
await api.post(`/api/catalog/admin/products`, body);
}
msg.value = "Gespeichert";
setTimeout(() => router.push("/products"), 500);
} catch (e: any) {
error.value = e.response?.data?.detail || e.message;
}
}
async function upload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
const r = await api.post("/api/catalog/admin/upload", fd);
form.value.image_url = r.data.url;
}
onMounted(async () => {
await loadCategories();
await loadProduct();
});
</script>
<template>
<div class="max-w-3xl">
<h1 class="text-2xl font-bold mb-4">{{ props.id ? "Produkt bearbeiten" : "Neues Produkt" }}</h1>
<form @submit.prevent="save" class="card space-y-4">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label">SKU</label>
<input v-model="form.sku" class="input" required />
</div>
<div>
<label class="label">Kategorie</label>
<select v-model="form.category_id" class="input">
<option :value="null"></option>
<option v-for="c in categories" :key="c.id" :value="c.id">
{{ c.name.de }}
</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label">Name (DE)</label>
<input v-model="form.name!.de" class="input" required />
</div>
<div>
<label class="label">Name (EN)</label>
<input v-model="form.name!.en" class="input" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="label">Beschreibung (DE)</label>
<textarea v-model="form.description!.de" class="input" rows="3" />
</div>
<div>
<label class="label">Beschreibung (EN)</label>
<textarea v-model="form.description!.en" class="input" rows="3" />
</div>
</div>
<div class="grid grid-cols-4 gap-3">
<div>
<label class="label">Preis</label>
<input v-model.number="form.price" type="number" step="0.01" class="input" required />
</div>
<div>
<label class="label">Währung</label>
<input v-model="form.currency" class="input" />
</div>
<div>
<label class="label">Bestand</label>
<input v-model.number="form.stock" type="number" class="input" />
</div>
<div class="flex items-end">
<label class="flex items-center gap-2">
<input v-model="form.active" type="checkbox" />
Aktiv
</label>
</div>
</div>
<div>
<label class="label">Bild</label>
<div class="flex items-center gap-3">
<div v-if="form.image_url" class="w-20 h-20 bg-gray-100 rounded overflow-hidden">
<img :src="form.image_url" class="w-full h-full object-cover" />
</div>
<input type="file" accept="image/*" @change="upload" />
</div>
</div>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" @click="router.push('/products')" class="btn-secondary">Abbrechen</button>
</div>
<div v-if="msg" class="text-green-600 text-sm">{{ msg }}</div>
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
</form>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { i18n } from "@shop/shared";
import type { Product } from "@shop/shared/types";
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { api } from "../api";
const products = ref<Product[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
const r = await api.get("/api/catalog/admin/products");
products.value = r.data;
} finally {
loading.value = false;
}
}
async function del(p: Product) {
if (!confirm(`Produkt ${p.sku} wirklich löschen?`)) return;
await api.delete(`/api/catalog/admin/products/${p.id}`);
await load();
}
onMounted(load);
</script>
<template>
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Produkte ({{ products.length }})</h1>
<RouterLink to="/products/new" class="btn-primary">+ Neues Produkt</RouterLink>
</div>
<div class="card">
<div v-if="loading" class="text-gray-500">Lädt...</div>
<table v-else class="data-table">
<thead>
<tr>
<th>ID</th>
<th>SKU</th>
<th>Name (DE)</th>
<th>Preis</th>
<th>Lager</th>
<th>Aktiv</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in products" :key="p.id">
<td>{{ p.id }}</td>
<td class="font-mono text-xs">{{ p.sku }}</td>
<td>{{ i18n.pickI18n(p.name, "de") }}</td>
<td>{{ p.price.toFixed(2) }} </td>
<td>{{ p.stock }}</td>
<td>{{ p.active ? "✓" : "" }}</td>
<td class="text-right">
<RouterLink :to="`/products/${p.id}`" class="text-admin-500 text-xs mr-2">Bearbeiten</RouterLink>
<button @click="del(p)" class="text-red-500 text-xs">Löschen</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api";
const shopName = ref("");
const msg = ref("");
const reindexing = ref(false);
const reindexResult = ref<any>(null);
async function load() {
try {
const r = await api.get("/api/core/settings/core.shop_name");
shopName.value = r.data.value || "";
} catch {
shopName.value = "";
}
}
async function save() {
msg.value = "";
await api.put("/api/core/settings/core.shop_name", { value: shopName.value });
msg.value = "✓ Gespeichert";
setTimeout(() => (msg.value = ""), 2000);
}
async function reindex() {
reindexing.value = true;
reindexResult.value = null;
try {
const r = await api.post("/api/ai_core/reindex");
reindexResult.value = r.data;
} finally {
reindexing.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="max-w-2xl">
<h1 class="text-2xl font-bold mb-4">Einstellungen</h1>
<div class="card space-y-3">
<div>
<label class="label">Shop-Name (core.shop_name)</label>
<div class="flex gap-2">
<input v-model="shopName" class="input" />
<button @click="save" class="btn-primary">Speichern</button>
</div>
<div v-if="msg" class="text-green-600 text-sm mt-1">{{ msg }}</div>
</div>
</div>
<div class="card mt-4 space-y-3">
<h2 class="font-semibold">KI-Index</h2>
<p class="text-sm text-gray-600">
Baut Embeddings für alle Produkte und Kategorien neu auf (nötig nach Seed oder bei
Inkonsistenzen).
</p>
<button @click="reindex" :disabled="reindexing" class="btn-secondary">
{{ reindexing ? "Indexiere..." : "Neu indizieren" }}
</button>
<div v-if="reindexResult" class="text-sm text-green-700">
{{ reindexResult.products }} Produkte, {{ reindexResult.categories }} Kategorien
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,24 @@
import { createRouter, createWebHistory } from "vue-router";
import Categories from "./pages/Categories.vue";
import Dashboard from "./pages/Dashboard.vue";
import Login from "./pages/Login.vue";
import OrderDetail from "./pages/OrderDetail.vue";
import Orders from "./pages/Orders.vue";
import ProductEdit from "./pages/ProductEdit.vue";
import Products from "./pages/Products.vue";
import Settings from "./pages/Settings.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", component: Login },
{ path: "/", component: Dashboard },
{ path: "/products", component: Products },
{ path: "/products/new", component: ProductEdit },
{ path: "/products/:id", component: ProductEdit, props: true },
{ path: "/categories", component: Categories },
{ path: "/orders", component: Orders },
{ path: "/orders/:id", component: OrderDetail, props: true },
{ path: "/settings", component: Settings },
],
});

View File

@@ -0,0 +1,47 @@
import { clearTokens, saveTokens } from "@shop/shared/api";
import type { User } from "@shop/shared/types";
import { defineStore } from "pinia";
import { api } from "../api";
export const useAuth = defineStore("auth", {
state: () => ({
user: null as User | null,
loading: false,
error: "",
}),
getters: {
isAdmin: (s) => s.user?.role === "admin",
},
actions: {
async fetchMe() {
try {
const r = await api.get("/api/auth/me");
this.user = r.data;
} catch {
this.user = null;
}
},
async login(email: string, password: string) {
this.loading = true;
this.error = "";
try {
const r = await api.post("/api/auth/login", { email, password });
if (r.data.role !== "admin") {
clearTokens();
throw new Error("Nur Admins dürfen sich hier anmelden.");
}
saveTokens(r.data.access_token, r.data.refresh_token);
await this.fetchMe();
} catch (e: any) {
this.error = e.response?.data?.detail || e.message;
throw e;
} finally {
this.loading = false;
}
},
logout() {
clearTokens();
this.user = null;
},
},
});

View File

@@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-100 text-gray-900;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.btn {
@apply inline-flex items-center justify-center rounded-md px-4 py-2 font-medium transition;
}
.btn-primary {
@apply btn bg-admin-500 text-white hover:bg-admin-600 disabled:opacity-50;
}
.btn-secondary {
@apply btn border border-gray-300 bg-white text-gray-700 hover:bg-gray-50;
}
.btn-danger {
@apply btn bg-red-500 text-white hover:bg-red-600;
}
.btn-success {
@apply btn bg-green-500 text-white hover:bg-green-600;
}
.input {
@apply w-full rounded-md border border-gray-300 px-3 py-2 focus:border-admin-500 focus:outline-none focus:ring-1 focus:ring-admin-500;
}
.card {
@apply rounded-lg bg-white p-4 shadow-sm border border-gray-200;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
table.data-table {
@apply w-full text-sm;
}
table.data-table th {
@apply text-left font-medium text-gray-600 border-b border-gray-200 px-2 py-2;
}
table.data-table td {
@apply border-b border-gray-100 px-2 py-2;
}

View File

@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,ts,js}"],
theme: {
extend: {
colors: {
admin: {
500: "#1565c0",
600: "#134a92",
700: "#0d3778",
},
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"paths": {
"@shop/shared": ["../shared/src/index.ts"],
"@shop/shared/*": ["../shared/src/*"]
}
},
"include": ["src/**/*", "src/**/*.vue"]
}

View File

@@ -0,0 +1,15 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
server: {
host: true,
port: 5174,
proxy: {
"/api": "http://localhost:8000",
"/uploads": "http://localhost:8000",
"/health": "http://localhost:8000",
},
},
});