feat: Add password reset functionality with request and reset forms

feat: Implement price list import feature with preview and apply options

feat: Create price rules management page with CRUD operations

feat: Develop quotes management page with itemized quotes and status tracking

feat: Introduce organization registration page for new users

feat: Build suppliers management page with detailed supplier information

feat: Create users management page for inviting and managing roles

chore: Add TypeScript configuration for improved type checking

chore: Set up Vite configuration for development server and API proxy

chore: Add Vite environment type definitions for better TypeScript support
This commit is contained in:
Torsten Schulz (local)
2026-06-02 15:28:38 +02:00
commit 0e539710c0
95 changed files with 31882 additions and 0 deletions

13
web-frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/companytool-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Company Tool</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1463
web-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
web-frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "companytool-web-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.5.0",
"vite": "^6.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.7.0",
"vue-tsc": "^2.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

399
web-frontend/src/App.vue Normal file
View File

@@ -0,0 +1,399 @@
<script setup lang="ts">
import { computed, onMounted, reactive, watch } from "vue";
import { useRouter } from "vue-router";
import { authState, clearAuthSession } from "./auth";
import { ensureConnection, stopConnection } from "./realtime";
import { privateRoutes, publicRoutes } from "./router";
import { loadUserSettings, saveNavigationMode, userSettings } from "./user-settings";
import logoUrl from "../../images/icons/companytool-logo.png";
import AdminRegistrationsPage from "./views/AdminRegistrationsPage.vue";
import OrganizationSetupPage from "./views/OrganizationSetupPage.vue";
import UsersPage from "./views/UsersPage.vue";
import CustomersPage from "./views/CustomersPage.vue";
import SuppliersPage from "./views/SuppliersPage.vue";
import ItemsPage from "./views/ItemsPage.vue";
import ActivitiesPage from "./views/ActivitiesPage.vue";
import CashDiscountTermsPage from "./views/CashDiscountTermsPage.vue";
import NumberRangesPage from "./views/NumberRangesPage.vue";
import QuotesPage from "./views/QuotesPage.vue";
import OutgoingInvoicesPage from "./views/OutgoingInvoicesPage.vue";
import IncomingInvoicesPage from "./views/IncomingInvoicesPage.vue";
import PriceImportsPage from "./views/PriceImportsPage.vue";
import ApiConnectorsPage from "./views/ApiConnectorsPage.vue";
import PriceRulesPage from "./views/PriceRulesPage.vue";
import CommunicationsPage from "./views/CommunicationsPage.vue";
import DocumentsPage from "./views/DocumentsPage.vue";
const router = useRouter();
const routes = computed(() => (authState.session ? privateRoutes : publicRoutes));
const activeNavGroup = reactive<{ label: string | null }>({ label: null });
const privateRouteGroups = computed(() => {
const groups: Array<{ label: string; routes: typeof privateRoutes }> = [];
for (const route of privateRoutes) {
const groupLabel = route.group;
let group = groups.find((item) => item.label === groupLabel);
if (!group) {
group = { label: groupLabel, routes: [] };
groups.push(group);
}
group.routes.push(route);
}
return groups;
});
const windowComponents = {
"/admin/organization-registrations": AdminRegistrationsPage,
"/setup/organization": OrganizationSetupPage,
"/settings/users": UsersPage,
"/settings/number-ranges": NumberRangesPage,
"/master-data/customers": CustomersPage,
"/master-data/suppliers": SuppliersPage,
"/master-data/items": ItemsPage,
"/settings/cash-discount-terms": CashDiscountTermsPage,
"/quotes": QuotesPage,
"/outgoing-invoices": OutgoingInvoicesPage,
"/incoming-invoices": IncomingInvoicesPage,
"/imports/price-lists": PriceImportsPage,
"/settings/api-connectors": ApiConnectorsPage,
"/settings/price-rules": PriceRulesPage,
"/communications": CommunicationsPage,
"/documents": DocumentsPage,
"/activities": ActivitiesPage
};
type WindowPath = keyof typeof windowComponents;
type AppWindow = {
path: WindowPath;
title: string;
z: number;
x: number;
y: number;
width: number;
height: number;
minimized: boolean;
};
const openWindows = reactive<AppWindow[]>([]);
let nextZ = 10;
let activeDrag:
| {
path: WindowPath;
mode: "move" | "resize";
pointerId: number;
startX: number;
startY: number;
windowX: number;
windowY: number;
windowWidth: number;
windowHeight: number;
}
| null = null;
onMounted(() => {
ensureConnection();
if (authState.session) {
loadUserSettings();
}
restoreWindows();
});
watch(
() => authState.session?.userId,
() => {
if (authState.session) {
loadUserSettings();
}
restoreWindows();
}
);
function logout() {
openWindows.splice(0);
clearAuthSession();
stopConnection();
router.push("/login");
}
function openAppWindow(route: { path: string; label: string }) {
activeNavGroup.label = null;
if (route.path === "/") {
router.push("/");
return;
}
if (!(route.path in windowComponents)) {
router.push(route.path);
return;
}
const path = route.path as keyof typeof windowComponents;
const existing = openWindows.find((window) => window.path === path);
if (existing) {
existing.minimized = false;
focusWindow(existing);
persistWindows();
return;
}
openWindows.push(defaultWindow(path, route.label, openWindows.length));
persistWindows();
}
function toggleGroup(label: string) {
activeNavGroup.label = activeNavGroup.label === label ? null : label;
}
function closeAppWindow(path: WindowPath) {
const index = openWindows.findIndex((window) => window.path === path);
if (index >= 0) {
openWindows.splice(index, 1);
persistWindows();
}
}
function minimizeAppWindow(path: WindowPath) {
const item = openWindows.find((window) => window.path === path);
if (!item) return;
item.minimized = true;
persistWindows();
}
function restoreAppWindow(item: AppWindow) {
item.minimized = false;
focusWindow(item);
}
function focusWindow(item: AppWindow) {
if (item.minimized) return;
item.z = ++nextZ;
persistWindows();
}
function startMove(event: PointerEvent, item: AppWindow) {
if ((event.target as HTMLElement).closest("button")) return;
activeDrag = {
path: item.path,
mode: "move",
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
windowX: item.x,
windowY: item.y,
windowWidth: item.width,
windowHeight: item.height
};
focusWindow(item);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function startResize(event: PointerEvent, item: AppWindow) {
activeDrag = {
path: item.path,
mode: "resize",
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
windowX: item.x,
windowY: item.y,
windowWidth: item.width,
windowHeight: item.height
};
focusWindow(item);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function updatePointer(event: PointerEvent) {
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return;
const item = openWindows.find((window) => window.path === activeDrag?.path);
if (!item) return;
const deltaX = event.clientX - activeDrag.startX;
const deltaY = event.clientY - activeDrag.startY;
if (activeDrag.mode === "move") {
item.x = clamp(activeDrag.windowX + deltaX, 252, window.innerWidth - 220);
item.y = clamp(activeDrag.windowY + deltaY, 8, window.innerHeight - 80);
} else {
item.width = clamp(activeDrag.windowWidth + deltaX, 560, window.innerWidth - item.x - 16);
item.height = clamp(activeDrag.windowHeight + deltaY, 360, window.innerHeight - item.y - 16);
}
}
function endPointer(event: PointerEvent) {
if (!activeDrag || activeDrag.pointerId !== event.pointerId) return;
activeDrag = null;
persistWindows();
}
function defaultWindow(path: WindowPath, title: string, index: number): AppWindow {
return {
path,
title,
z: ++nextZ,
x: 280 + index * 28,
y: 42 + index * 24,
width: 900,
height: 680,
minimized: false
};
}
function windowStorageKey() {
return `companytool.windows.${authState.session?.userId ?? "anonymous"}`;
}
function persistWindows() {
if (!authState.session) return;
window.localStorage.setItem(
windowStorageKey(),
JSON.stringify(
openWindows.map(({ path, title, z, x, y, width, height, minimized }) => ({
path,
title,
z,
x,
y,
width,
height,
minimized
}))
)
);
}
function restoreWindows() {
openWindows.splice(0);
if (!authState.session) return;
try {
const raw = window.localStorage.getItem(windowStorageKey());
if (!raw) return;
const saved = JSON.parse(raw) as AppWindow[];
for (const item of saved) {
if (item.path in windowComponents) {
openWindows.push({
...item,
z: ++nextZ,
x: clamp(item.x, 252, window.innerWidth - 220),
y: clamp(item.y, 8, window.innerHeight - 80),
width: clamp(item.width, 560, window.innerWidth - item.x - 16),
height: clamp(item.height, 360, window.innerHeight - item.y - 64),
minimized: item.minimized === true
});
}
}
} catch {
openWindows.splice(0);
}
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, Math.max(min, max)));
}
</script>
<template>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<img class="brand-mark" :src="logoUrl" alt="Company Tool Logo" />
<div>
<strong>Company Tool</strong>
<span>Organisationen</span>
</div>
</div>
<nav class="nav" :class="`nav-${userSettings.navigationMode}`">
<template v-if="!authState.session">
<RouterLink
v-for="route in routes"
:key="route.path"
:to="route.path"
class="nav-link"
>
{{ route.label }}
</RouterLink>
</template>
<template v-if="authState.session && userSettings.navigationMode === 'groups'">
<template v-for="group in privateRouteGroups" :key="group.label">
<div class="nav-dropdown">
<button type="button" class="nav-group-toggle" @click="toggleGroup(group.label)">
<span>{{ group.label }}</span>
<span>{{ activeNavGroup.label === group.label ? "^" : "v" }}</span>
</button>
<div v-if="activeNavGroup.label === group.label" class="nav-dropdown-menu">
<button
v-for="route in group.routes"
:key="route.path"
type="button"
class="nav-link nav-button"
@click="openAppWindow(route)"
>
{{ route.label }}
</button>
</div>
</div>
</template>
</template>
<template v-else-if="authState.session">
<template v-for="group in privateRouteGroups" :key="group.label">
<div class="nav-group-title">{{ group.label }}</div>
<button
v-for="route in group.routes"
:key="route.path"
type="button"
class="nav-link nav-button"
:class="{ active: route.path === '/' }"
@click="openAppWindow(route)"
>
{{ route.label }}
</button>
</template>
</template>
</nav>
<div v-if="authState.session" class="session-box">
<label class="field settings-field">
<span>Menü</span>
<select
:value="userSettings.navigationMode"
@change="saveNavigationMode(($event.target as HTMLSelectElement).value as 'scroll' | 'groups')"
>
<option value="scroll">Scrollbar</option>
<option value="groups">Gruppen einklappen</option>
</select>
</label>
<small v-if="userSettings.status">{{ userSettings.status }}</small>
<span>{{ authState.session.email }}</span>
<button type="button" class="secondary" @click="logout">Abmelden</button>
</div>
</aside>
<main class="main">
<RouterView />
<section
v-for="item in openWindows"
v-show="!item.minimized"
:key="item.path"
class="app-window"
:style="{ zIndex: item.z, left: `${item.x}px`, top: `${item.y}px`, width: `${item.width}px`, height: `${item.height}px` }"
@mousedown="focusWindow(item)"
@pointermove="updatePointer"
@pointerup="endPointer"
@pointercancel="endPointer"
>
<header class="app-window-title" @pointerdown="startMove($event, item)">
<strong>{{ item.title }}</strong>
<span class="app-window-actions">
<button type="button" class="secondary icon-button" @click="minimizeAppWindow(item.path)">_</button>
<button type="button" class="secondary icon-button" @click="closeAppWindow(item.path)">×</button>
</span>
</header>
<div class="app-window-body">
<component :is="windowComponents[item.path]" />
</div>
<span class="app-window-resize" @pointerdown="startResize($event, item)" />
</section>
<footer v-if="openWindows.length" class="taskbar">
<button
v-for="item in openWindows"
:key="item.path"
type="button"
class="taskbar-button"
:class="{ minimized: item.minimized }"
@click="restoreAppWindow(item)"
>
{{ item.title }}
</button>
</footer>
</main>
</div>
</template>

51
web-frontend/src/api.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { ApiResult } from "./types";
import { authState } from "./auth";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
export function apiGet<T>(path: string): Promise<ApiResult<T>> {
return apiRequest<T>("GET", path);
}
export function apiPost<T>(path: string, body: unknown): Promise<ApiResult<T>> {
return apiRequest<T>("POST", path, body);
}
export function apiPut<T>(path: string, body: unknown): Promise<ApiResult<T>> {
return apiRequest<T>("PUT", path, body);
}
export function apiPatch<T>(path: string, body: unknown): Promise<ApiResult<T>> {
return apiRequest<T>("PATCH", path, body);
}
export function apiDelete<T>(path: string): Promise<ApiResult<T>> {
return apiRequest<T>("DELETE", path);
}
async function apiRequest<T>(method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
try {
const response = await fetch(`${apiBaseUrl}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...(authState.session?.accessToken ? { Authorization: `Bearer ${authState.session.accessToken}` } : {})
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
return { ok: false, message: data.message ?? `HTTP ${response.status}` };
}
return { ok: true, data };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : "Unbekannter Fehler"
};
}
}

44
web-frontend/src/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import { reactive } from "vue";
import type { AuthSession } from "./types";
const authStorageKey = "companytool.auth";
export const authState = reactive<{
session: AuthSession | null;
}>({
session: loadAuthSession()
});
export function setAuthSession(session: AuthSession) {
authState.session = session;
window.localStorage.setItem(authStorageKey, JSON.stringify(session));
}
export function updateAuthSession(partial: Partial<AuthSession>) {
if (!authState.session) return;
setAuthSession({ ...authState.session, ...partial });
}
export function clearAuthSession() {
authState.session = null;
window.localStorage.removeItem(authStorageKey);
}
function loadAuthSession(): AuthSession | null {
try {
const raw = window.localStorage.getItem(authStorageKey);
if (!raw) return null;
const session = JSON.parse(raw) as Partial<AuthSession>;
if (!session.email || !session.userId) return null;
if (!session.accessToken) return null;
return {
email: session.email,
userId: session.userId,
accessToken: session.accessToken,
organizationId: session.organizationId ?? null,
mustChangePassword: session.mustChangePassword === true
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
defineProps<{
message: string;
kind?: "info" | "success" | "error";
}>();
</script>
<template>
<output v-if="message" :class="['form-status', kind ?? 'info']">{{ message }}</output>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{
title: string;
description: string;
}>();
</script>
<template>
<header class="page-header">
<div>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</div>
</header>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{
modelValue?: string | null;
options: Array<{ id: string; number?: string | null; name: string }>;
placeholder?: string;
allowEmpty?: boolean;
emptyLabel?: string;
required?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string | null];
change: [];
}>();
const query = ref("");
const selected = computed(() => props.options.find((option) => option.id === props.modelValue));
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
const filteredOptions = computed(() => {
const activeQuery = normalizedQuery.value;
const matches = activeQuery
? props.options.filter((option) => {
const haystack = `${option.number ?? ""} ${option.name}`.toLowerCase();
return haystack.includes(activeQuery);
})
: props.options;
return matches.slice(0, 20);
});
function select(value: string | null) {
emit("update:modelValue", value);
emit("change");
}
</script>
<template>
<div class="search-select">
<input
v-model="query"
type="search"
:placeholder="placeholder ?? 'Nach Nummer oder Name suchen'"
:required="required && !modelValue"
/>
<div v-if="selected" class="search-select-current">
Ausgewählt: <strong>{{ selected.number ? `${selected.number} - ${selected.name}` : selected.name }}</strong>
</div>
<div class="search-select-options">
<button
v-if="allowEmpty"
type="button"
class="search-option"
:class="{ selected: modelValue === null || modelValue === '' }"
@click="select(null)"
>
{{ emptyLabel ?? "Keine Auswahl" }}
</button>
<button
v-for="option in filteredOptions"
:key="option.id"
type="button"
class="search-option"
:class="{ selected: modelValue === option.id }"
@click="select(option.id)"
>
<span>{{ option.number }}</span>
<strong>{{ option.name }}</strong>
</button>
<p v-if="filteredOptions.length === 0" class="muted">Keine Treffer.</p>
</div>
</div>
</template>

6
web-frontend/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from "vue";
import "./styles.css";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(router).mount("#app");

View File

@@ -0,0 +1,17 @@
import { apiPost } from "./api";
type NextNumberResponse = {
code: string;
number: string;
};
export async function reserveNextNumber(code: string): Promise<string | null> {
const result = await apiPost<NextNumberResponse>(`/api/v1/number-ranges/${encodeURIComponent(code)}/next`, {});
return result.ok ? result.data.number : null;
}
export function matchesObjectSearch(number: string | null | undefined, title: string | null | undefined, query: string) {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return `${number ?? ""} ${title ?? ""}`.toLowerCase().includes(needle);
}

View File

@@ -0,0 +1,179 @@
import { reactive } from "vue";
import type {
ClientMessage,
EncryptedEnvelope,
RecordSummary,
ServerMessage,
SessionCrypto,
WireMessage
} from "./types";
const protocolVersion = 1;
export const wsUrl = import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws";
export const connectionState = reactive<{
records: RecordSummary[];
status: string;
}>({
records: [],
status: "Nicht verbunden"
});
export const liveUpdateState = reactive<{
revision: number;
lastTitle: string;
lastUpdatedAt: string | null;
}>({
revision: 0,
lastTitle: "",
lastUpdatedAt: null
});
let connectionStarted = false;
let reconnectTimer: number | undefined;
export function ensureConnection() {
if (connectionStarted) return;
connectionStarted = true;
connect();
}
export function stopConnection() {
connectionStarted = false;
if (reconnectTimer !== undefined) {
window.clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
connectionState.records = [];
connectionState.status = "Nicht verbunden";
}
function connect() {
const socket = new WebSocket(wsUrl);
let session: SessionCrypto | null = null;
socket.addEventListener("open", async () => {
session = await createSessionCrypto();
connectionState.status = "Handshake...";
socket.send(JSON.stringify({
type: "hello",
payload: {
protocol_version: protocolVersion,
key_id: session.keyId,
session_key: session.exportedKey
}
} satisfies WireMessage));
});
socket.addEventListener("message", async (event) => {
const wireMessage = JSON.parse(event.data) as WireMessage;
if (wireMessage.type === "hello_ack") {
connectionState.status = "Verbunden";
if (session) {
const envelope = await encryptMessage(session, {
type: "subscribe",
payload: { topic: "records" }
});
socket.send(JSON.stringify({ type: "encrypted", payload: envelope } satisfies WireMessage));
}
return;
}
if (wireMessage.type === "encrypted" && session) {
const message = await decryptMessage<ServerMessage>(session, wireMessage.payload);
applyMessage(message);
return;
}
if (wireMessage.type === "error") {
connectionState.status = wireMessage.payload.message;
}
});
socket.addEventListener("close", () => {
connectionStarted = false;
connectionState.status = "Verbindung getrennt, neuer Versuch...";
reconnectTimer = window.setTimeout(ensureConnection, 1500);
});
socket.addEventListener("error", () => {
connectionState.status = "Socket-Fehler";
});
}
async function createSessionCrypto(): Promise<SessionCrypto> {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
"encrypt",
"decrypt"
]);
const rawKey = await crypto.subtle.exportKey("raw", key);
return {
key,
keyId: crypto.randomUUID(),
exportedKey: bytesToBase64(new Uint8Array(rawKey))
};
}
async function encryptMessage(session: SessionCrypto, message: ClientMessage) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(message));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, session.key, encoded);
return {
enc: `aes-256-gcm-v${protocolVersion}`,
key_id: session.keyId,
nonce: bytesToBase64(nonce),
ciphertext: bytesToBase64(new Uint8Array(ciphertext))
} satisfies EncryptedEnvelope;
}
async function decryptMessage<T>(session: SessionCrypto, envelope: EncryptedEnvelope): Promise<T> {
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBytes(envelope.nonce) },
session.key,
base64ToBytes(envelope.ciphertext)
);
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
function base64ToBytes(value: string) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function applyMessage(message: ServerMessage) {
if (message.type === "snapshot") {
connectionState.records = message.payload.records;
connectionState.status = "Verbunden";
}
if (message.type === "record_changed") {
const index = connectionState.records.findIndex((record) => record.id === message.payload.record.id);
if (index >= 0) {
connectionState.records[index] = message.payload.record;
} else {
connectionState.records.unshift(message.payload.record);
}
connectionState.status = "Aktualisiert";
liveUpdateState.revision += 1;
liveUpdateState.lastTitle = message.payload.record.title;
liveUpdateState.lastUpdatedAt = message.payload.record.updated_at;
}
if (message.type === "error") {
connectionState.status = message.payload.message;
}
}

105
web-frontend/src/router.ts Normal file
View File

@@ -0,0 +1,105 @@
import { createRouter, createWebHistory } from "vue-router";
import { authState } from "./auth";
import DashboardPage from "./views/DashboardPage.vue";
import LoginPage from "./views/LoginPage.vue";
import RegisterPage from "./views/RegisterPage.vue";
import ChangeInitialPasswordPage from "./views/ChangeInitialPasswordPage.vue";
import PasswordResetPage from "./views/PasswordResetPage.vue";
import AcceptInvitationPage from "./views/AcceptInvitationPage.vue";
import AdminRegistrationsPage from "./views/AdminRegistrationsPage.vue";
import AdminRegistrationDetailPage from "./views/AdminRegistrationDetailPage.vue";
import OrganizationSetupPage from "./views/OrganizationSetupPage.vue";
import UsersPage from "./views/UsersPage.vue";
import CustomersPage from "./views/CustomersPage.vue";
import SuppliersPage from "./views/SuppliersPage.vue";
import ItemsPage from "./views/ItemsPage.vue";
import ActivitiesPage from "./views/ActivitiesPage.vue";
import CashDiscountTermsPage from "./views/CashDiscountTermsPage.vue";
import NumberRangesPage from "./views/NumberRangesPage.vue";
import QuotesPage from "./views/QuotesPage.vue";
import OutgoingInvoicesPage from "./views/OutgoingInvoicesPage.vue";
import IncomingInvoicesPage from "./views/IncomingInvoicesPage.vue";
import PriceImportsPage from "./views/PriceImportsPage.vue";
import ApiConnectorsPage from "./views/ApiConnectorsPage.vue";
import PriceRulesPage from "./views/PriceRulesPage.vue";
import CommunicationsPage from "./views/CommunicationsPage.vue";
import DocumentsPage from "./views/DocumentsPage.vue";
export const publicRoutes = [
{ path: "/login", label: "Login" },
{ path: "/register", label: "Registrierung" },
{ path: "/password-reset", label: "Passwort zurücksetzen" },
{ path: "/accept-invitation", label: "Einladung annehmen" }
];
export const privateRoutes = [
{ path: "/", label: "Dashboard", group: "Vorgänge" },
{ path: "/outgoing-invoices", label: "Ausgangsrechnungen", group: "Vorgänge" },
{ path: "/quotes", label: "Angebote", group: "Vorgänge" },
{ path: "/incoming-invoices", label: "Eingangsrechnungen", group: "Vorgänge" },
{ path: "/master-data/customers", label: "Kunden", group: "Stammdaten" },
{ path: "/master-data/suppliers", label: "Lieferanten", group: "Stammdaten" },
{ path: "/master-data/items", label: "Artikel", group: "Stammdaten" },
{ path: "/activities", label: "Aktivitäten", group: "Stammdaten" },
{ path: "/imports/price-lists", label: "Preislisten", group: "Arbeitsdaten" },
{ path: "/communications", label: "Kommunikation", group: "Arbeitsdaten" },
{ path: "/documents", label: "Dokumente", group: "Arbeitsdaten" },
{ path: "/settings/price-rules", label: "Preisregeln", group: "Einstellungen" },
{ path: "/settings/api-connectors", label: "Preis-APIs", group: "Einstellungen" },
{ path: "/setup/organization", label: "Firmendaten", group: "Einstellungen" },
{ path: "/settings/users", label: "Benutzerrechte", group: "Einstellungen" },
{ path: "/settings/number-ranges", label: "Nummernkreise", group: "Einstellungen" },
{ path: "/settings/cash-discount-terms", label: "Skonto", group: "Einstellungen" },
{ path: "/admin/organization-registrations", label: "Freischaltung", group: "Einstellungen" }
];
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: DashboardPage, meta: { requiresAuth: true } },
{ path: "/login", component: LoginPage, meta: { publicOnly: true } },
{ path: "/register", component: RegisterPage, meta: { publicOnly: true } },
{ path: "/password-reset", component: PasswordResetPage, meta: { publicOnly: true } },
{ path: "/accept-invitation", component: AcceptInvitationPage, meta: { publicOnly: true } },
{
path: "/change-initial-password",
component: ChangeInitialPasswordPage,
meta: { requiresAuth: true, passwordChange: true }
},
{
path: "/admin/organization-registrations",
component: AdminRegistrationsPage,
meta: { requiresAuth: true }
},
{
path: "/admin/organization-registrations/:id",
component: AdminRegistrationDetailPage,
meta: { requiresAuth: true }
},
{ path: "/setup/organization", component: OrganizationSetupPage, meta: { requiresAuth: true } },
{ path: "/settings/users", component: UsersPage, meta: { requiresAuth: true } },
{ path: "/settings/number-ranges", component: NumberRangesPage, meta: { requiresAuth: true } },
{ path: "/master-data/customers", component: CustomersPage, meta: { requiresAuth: true } },
{ path: "/master-data/suppliers", component: SuppliersPage, meta: { requiresAuth: true } },
{ path: "/master-data/items", component: ItemsPage, meta: { requiresAuth: true } },
{ path: "/settings/cash-discount-terms", component: CashDiscountTermsPage, meta: { requiresAuth: true } },
{ path: "/quotes", component: QuotesPage, meta: { requiresAuth: true } },
{ path: "/outgoing-invoices", component: OutgoingInvoicesPage, meta: { requiresAuth: true } },
{ path: "/incoming-invoices", component: IncomingInvoicesPage, meta: { requiresAuth: true } },
{ path: "/imports/price-lists", component: PriceImportsPage, meta: { requiresAuth: true } },
{ path: "/settings/api-connectors", component: ApiConnectorsPage, meta: { requiresAuth: true } },
{ path: "/settings/price-rules", component: PriceRulesPage, meta: { requiresAuth: true } },
{ path: "/communications", component: CommunicationsPage, meta: { requiresAuth: true } },
{ path: "/documents", component: DocumentsPage, meta: { requiresAuth: true } },
{ path: "/activities", component: ActivitiesPage, meta: { requiresAuth: true } },
{ path: "/:pathMatch(.*)*", redirect: "/login" }
]
});
router.beforeEach((to) => {
const session = authState.session;
if (!session && to.meta.requiresAuth) return "/login";
if (session?.mustChangePassword && !to.meta.passwordChange) return "/change-initial-password";
if (session && to.meta.publicOnly) return "/";
return true;
});

788
web-frontend/src/styles.css Normal file
View File

@@ -0,0 +1,788 @@
:root {
color: #172026;
background: #f6faf9;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
background: #118a7f;
border: 1px solid #118a7f;
border-radius: 6px;
color: #ffffff;
cursor: pointer;
font-weight: 700;
min-height: 38px;
padding: 8px 13px;
}
button.secondary {
background: #ffffff;
border-color: #c9d3d6;
color: #26343b;
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
button:hover {
filter: brightness(0.96);
}
a {
color: #118a7f;
text-decoration: none;
}
code {
background: #e8edef;
border: 1px solid #ccd6d9;
border-radius: 6px;
color: #26343b;
padding: 4px 7px;
overflow-wrap: anywhere;
}
.app-shell {
display: grid;
grid-template-columns: 244px minmax(0, 1fr);
height: 100vh;
min-height: 0;
overflow: hidden;
}
.sidebar {
background: #ffffff;
border-right: 1px solid #dbe3e6;
display: flex;
flex-direction: column;
min-height: 0;
padding: 22px 16px;
}
.brand {
align-items: center;
display: flex;
flex-shrink: 0;
gap: 10px;
margin-bottom: 26px;
}
.brand-mark {
background: #ffffff;
border-radius: 6px;
display: block;
height: 34px;
object-fit: contain;
width: 34px;
}
.brand strong,
.brand span {
display: block;
}
.brand span {
color: #6a787d;
font-size: 13px;
}
.brand strong {
color: #172026;
}
.nav {
display: grid;
gap: 0;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.nav-group-title {
color: #6a787d;
font-size: 11px;
font-weight: 800;
letter-spacing: 0;
margin: 0 0 0 0;
}
.nav-link + .nav-link {
margin-top: 4px;
}
.nav-link + .nav-group-title {
margin-top: 12px;
}
.nav-link {
border-radius: 6px;
color: #334349;
font-weight: 650;
line-height: 1.2;
padding: 9px 10px;
}
.nav-button {
background: #10545c;
border: 1px solid #10545c;
color: #eefbf8;
font-size: 12px;
font-weight: 500;
min-height: 36px;
padding: 8px 10px;
text-align: center;
}
.nav-button:hover {
background: #118a7f;
border-color: #118a7f;
color: #ffffff;
}
.nav-button.active {
background: #def4f0;
border-color: #def4f0;
color: #10545c;
font-weight: 500;
}
.nav-groups {
overflow: visible;
}
.nav-group-toggle {
align-items: center;
background: #f4f7f8;
border-color: #d8e2e5;
color: #334349;
display: flex;
font-size: 12px;
justify-content: space-between;
margin-top: 0;
min-height: 32px;
padding: 6px 9px;
width: 100%;
}
.nav-dropdown {
position: relative;
}
.nav-groups .nav-dropdown:not(:first-child) {
margin-top: 12px;
}
.nav-dropdown-menu {
background: #ffffff;
border: 1px solid #cfd9dc;
border-radius: 6px;
box-shadow: 0 14px 34px rgb(28 43 48 / 16%);
display: grid;
gap: 2px;
left: 0;
min-width: 190px;
padding: 6px;
position: absolute;
top: calc(100% + 4px);
z-index: 80;
}
.nav-dropdown-menu .nav-link {
font-size: 13px;
font-weight: 750;
line-height: 1.2;
min-height: 30px;
padding: 6px 8px;
white-space: nowrap;
}
.nav-link.active,
.nav-link:hover {
background: #def4f0;
color: #10545c;
}
.session-box {
border-top: 1px solid #dbe3e6;
display: grid;
flex-shrink: 0;
gap: 10px;
margin-top: 22px;
padding-top: 16px;
}
.session-box span {
color: #435258;
font-size: 13px;
font-weight: 700;
overflow-wrap: anywhere;
}
.session-box button {
background: #10545c;
border-color: #10545c;
color: #eefbf8;
font-size: 12px;
font-weight: 500;
min-height: 34px;
width: 100%;
}
.settings-field {
gap: 5px;
}
.settings-field span,
.session-box small {
color: #65757b;
font-size: 12px;
font-weight: 700;
}
.settings-field select {
background: #10545c;
border: 1px solid #10545c;
border-radius: 6px;
color: #ffffff;
font-size: 12px;
min-height: 34px;
padding: 4px 8px;
}
.main {
overflow: auto;
min-width: 0;
padding: 28px 28px 64px;
position: relative;
}
.app-window {
background: #ffffff;
border: 1px solid #cfd9dc;
border-radius: 8px;
box-shadow: 0 18px 50px rgb(28 43 48 / 18%);
overflow: hidden;
position: fixed;
}
.app-window-title {
align-items: center;
background: #eef8f6;
border-bottom: 1px solid #dbe3e6;
cursor: move;
display: flex;
justify-content: space-between;
padding: 9px 12px;
touch-action: none;
}
.app-window-actions {
display: flex;
gap: 6px;
}
.app-window-body {
height: calc(100% - 49px);
overflow: auto;
padding: 18px;
}
.app-window-resize {
border-bottom: 12px solid #8fa0a6;
border-left: 12px solid transparent;
bottom: 5px;
cursor: nwse-resize;
height: 0;
position: absolute;
right: 5px;
touch-action: none;
width: 0;
}
.icon-button {
min-height: 30px;
padding: 2px 10px;
}
.taskbar {
align-items: center;
background: #ffffff;
border-top: 1px solid #cfd9dc;
bottom: 0;
display: flex;
gap: 8px;
left: 244px;
min-height: 48px;
overflow-x: auto;
padding: 7px 12px;
position: fixed;
right: 0;
z-index: 2000;
}
.taskbar-button {
background: #10545c;
border-color: #10545c;
color: #ffffff;
flex: 0 0 auto;
font-size: 12px;
font-weight: 600;
min-height: 32px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.taskbar-button.minimized {
background: #def4f0;
border-color: #c8ebe6;
color: #10545c;
}
.page-header {
align-items: flex-start;
display: flex;
justify-content: space-between;
margin-bottom: 18px;
}
.page-header h1 {
font-size: 28px;
line-height: 1.15;
margin: 0 0 6px;
}
.page-header p,
.muted {
color: #65757b;
margin: 0;
}
.panel {
background: #ffffff;
border: 1px solid #d9e1e4;
border-radius: 8px;
margin-bottom: 18px;
padding: 18px;
}
.panel.compact {
padding: 15px 18px;
}
.sub-panel {
border-top: 1px solid #dbe3e6;
display: grid;
gap: 8px;
margin-top: 18px;
padding-top: 16px;
}
.sub-panel h2 {
font-size: 18px;
margin: 0;
}
.data-row {
align-items: center;
border: 1px solid #dbe3e6;
border-radius: 6px;
display: grid;
gap: 8px;
grid-template-columns: minmax(180px, 1.2fr) repeat(3, minmax(70px, 0.6fr));
padding: 9px 10px;
}
.data-row small {
color: #65757b;
}
.quote-line {
border: 1px solid #dbe3e6;
border-radius: 8px;
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(120px, 1fr));
padding: 12px;
}
.form-panel {
max-width: 720px;
}
.form-panel.wide {
max-width: 1040px;
}
.workspace-split {
display: grid;
gap: 16px;
grid-template-columns: 280px minmax(420px, 1fr);
}
.list-panel,
.detail-panel {
margin-bottom: 0;
}
.list-row {
align-items: flex-start;
background: #ffffff;
border: 1px solid transparent;
color: #26343b;
display: grid;
font-weight: 400;
gap: 3px;
margin-bottom: 5px;
padding: 10px;
text-align: left;
width: 100%;
}
.list-row:hover,
.list-row.selected {
background: #def4f0;
border-color: #c6d9d8;
filter: none;
}
.list-row strong {
font-size: 13px;
}
.list-row span {
font-weight: 650;
}
.list-row small {
color: #65757b;
}
.split-row,
.section-title,
.button-row,
.form-actions {
align-items: center;
display: flex;
gap: 12px;
}
.split-row,
.section-title {
justify-content: space-between;
}
.section-title {
margin-bottom: 14px;
}
.section-title h2,
.split-row h2 {
font-size: 18px;
margin: 0 0 4px;
}
form {
display: grid;
gap: 14px;
}
.form-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field {
display: grid;
gap: 6px;
}
.field span,
.check-row span {
color: #435258;
font-size: 14px;
font-weight: 700;
}
input,
select,
textarea {
background: #ffffff;
border: 1px solid #cbd5d9;
border-radius: 6px;
color: #172026;
min-height: 38px;
padding: 8px 10px;
width: 100%;
}
.readonly-value {
align-items: center;
background: #f6faf9;
border: 1px solid #cbd5d9;
border-radius: 6px;
color: #172026;
display: flex;
min-height: 38px;
padding: 8px 10px;
}
.list-search {
margin-bottom: 10px;
}
textarea {
resize: vertical;
}
.field.full-width {
grid-column: 1 / -1;
}
select[multiple] {
min-height: 128px;
}
.search-select {
display: grid;
gap: 8px;
}
.search-select-current {
color: #435258;
font-size: 13px;
}
.search-select-options {
border: 1px solid #dbe3e6;
border-radius: 6px;
display: grid;
gap: 4px;
max-height: 220px;
overflow: auto;
padding: 6px;
}
.search-option {
background: #ffffff;
border: 1px solid transparent;
color: #26343b;
display: grid;
gap: 2px;
min-height: 0;
padding: 7px 8px;
text-align: left;
}
.search-option span {
color: #65757b;
font-size: 12px;
}
.search-option strong {
font-size: 13px;
}
.search-option:hover,
.search-option.selected {
background: #def4f0;
border-color: #c6d9d8;
filter: none;
}
.check-row {
align-items: center;
display: flex;
gap: 9px;
}
.check-row input {
min-height: 16px;
width: 16px;
}
.check-row.compact {
gap: 6px;
}
.check-row.compact span {
font-size: 13px;
}
.role-checks {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.form-actions,
.button-row {
flex-wrap: wrap;
margin-top: 4px;
}
.form-status {
border-radius: 6px;
display: block;
min-height: 20px;
padding: 0;
}
.form-status.info,
.form-status.success,
.form-status.error {
padding: 10px 12px;
}
.form-status.info {
background: #eef8f6;
color: #334349;
}
.form-status.success {
background: #e6f4ea;
color: #1f5d38;
}
.form-status.error {
background: #fdeceb;
color: #8a2521;
}
.data-table {
border: 1px solid #e0e7ea;
border-radius: 8px;
display: grid;
overflow: hidden;
}
.data-table.two-cols {
grid-template-columns: minmax(0, 1fr) 220px;
}
.data-table.registrations {
grid-template-columns: minmax(140px, 1.2fr) minmax(170px, 1fr) 140px 180px;
}
.data-table.users {
grid-template-columns: minmax(180px, 1fr) 140px minmax(260px, 1.2fr) 130px;
}
.data-table > * {
border-bottom: 1px solid #edf1f3;
min-width: 0;
padding: 12px 14px;
}
.table-head {
background: #eef8f6;
color: #526268;
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
}
.status-pill {
background: #def4f0;
border-radius: 999px;
color: #118a7f;
display: inline-block;
font-size: 13px;
font-weight: 800;
height: 28px;
line-height: 28px;
margin: 8px 12px;
padding: 0 10px;
}
.detail-grid {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
margin: 0 0 16px;
}
.detail-grid dt,
.detail-grid dd {
border-bottom: 1px solid #edf1f3;
margin: 0;
padding: 10px 0;
}
.detail-grid dt {
color: #65757b;
font-weight: 800;
}
.empty {
background: #f7f9fa;
border: 1px dashed #cbd5d9;
border-radius: 8px;
color: #65757b;
margin: 0;
padding: 18px;
}
@media (max-width: 860px) {
.app-shell {
grid-template-columns: 1fr;
height: auto;
min-height: 100vh;
overflow: visible;
}
.sidebar {
border-bottom: 1px solid #dbe3e6;
border-right: 0;
max-height: none;
}
.nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow-y: visible;
}
.main {
padding: 18px;
}
.taskbar {
left: 0;
}
.form-grid,
.workspace-split,
.data-table.two-cols,
.data-table.registrations,
.data-table.users,
.detail-grid {
grid-template-columns: 1fr;
}
.split-row,
.section-title {
align-items: flex-start;
flex-direction: column;
}
}

348
web-frontend/src/types.ts Normal file
View File

@@ -0,0 +1,348 @@
export type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; message: string };
export type OrganizationRegistration = {
id: string;
organization_name?: string;
email: string;
status: string;
requested_at: string;
decided_at?: string | null;
decided_by_user_id?: string | null;
};
export type OrganizationRegistrationDetail = OrganizationRegistration & {
organization_id?: string | null;
schema_name?: string | null;
provisioning_error?: string | null;
decision_note?: string | null;
};
export type OrganizationUser = {
user_id: string;
email: string;
status: string;
roles: string[];
};
export type Customer = {
id: string;
customer_number: string;
name: string;
status: string;
details: {
street: string;
postal_code: string;
city: string;
country: string;
email: string;
phone: string;
};
standard_discount_percent: string;
cash_discount_term_id?: string | null;
};
export type Supplier = Omit<Customer, "customer_number"> & {
supplier_number: string;
payment_days?: number | null;
};
export type Item = {
id: string;
item_number: string;
name: string;
unit: string;
tax_rate: string;
default_purchase_price?: string | null;
default_sales_price?: string | null;
status: string;
};
export type ItemPriceHistory = {
id: string;
item_id: string;
purchase_price?: string | null;
sales_price?: string | null;
source: string;
valid_from: string;
created_by_user_id?: string | null;
created_at: string;
};
export type CashDiscountTerm = {
id: string;
code: string;
name: string;
discount_percent: string;
discount_days: number;
net_days?: number | null;
valid_from?: string | null;
valid_until?: string | null;
is_default_customer_term: boolean;
is_default_supplier_term: boolean;
is_active: boolean;
};
export type Activity = {
id: string;
activity_number?: string | null;
activity_type: string;
title: string;
body: string;
status: string;
priority: string;
due_at?: string | null;
};
export type NumberRange = {
id: string;
code: string;
pattern: string;
counter_value: number;
counter_padding: number;
reset_rule?: string | null;
is_active: boolean;
};
export type QuoteItem = {
id?: string;
line_number?: number;
item_id: string;
description: string;
quantity: string;
unit_price: string;
original_unit_price?: string | null;
discount_percent: string;
tax_rate: string;
price_overridden?: boolean;
};
export type Quote = {
id: string;
quote_number: string;
customer_id: string;
status: string;
valid_until?: string | null;
cash_discount_term_id?: string | null;
customer_discount_percent: string;
notes: string;
items: QuoteItem[];
};
export type OutgoingInvoiceItem = QuoteItem;
export type OutgoingInvoice = {
id: string;
invoice_number: string;
customer_id: string;
status: string;
cash_discount_term_id?: string | null;
customer_discount_percent: string;
issued_at?: string | null;
due_at?: string | null;
source_quote_id?: string | null;
finalized_at?: string | null;
items: OutgoingInvoiceItem[];
};
export type IncomingInvoiceItem = {
id?: string;
line_number?: number;
item_id?: string | null;
description: string;
quantity: string;
unit_price: string;
tax_rate: string;
};
export type IncomingInvoice = {
id: string;
invoice_number: string;
supplier_id: string;
status: string;
cash_discount_term_id?: string | null;
invoice_date?: string | null;
due_at?: string | null;
items: IncomingInvoiceItem[];
};
export type PriceListImportRow = {
row_number: number;
item_number: string;
name: string;
unit: string;
tax_rate: string;
purchase_price?: string | null;
sales_price?: string | null;
action: string;
error?: string | null;
};
export type PriceListImportPreview = {
rows: PriceListImportRow[];
total_rows: number;
valid_rows: number;
error_rows: number;
};
export type PriceListImportApplyResponse = {
import_id: string;
applied_rows: number;
error_rows: number;
};
export type ApiConnector = {
id: string;
code: string;
name: string;
connector_type: string;
config: Record<string, unknown>;
is_active: boolean;
sync_interval_minutes?: number | null;
last_sync_at?: string | null;
};
export type PriceRule = {
id: string;
code: string;
name: string;
source_type: "import" | "api" | "supplier";
source_id?: string | null;
markup_percent: string;
rounding_mode: "none" | "cent" | "five_cent" | "ten_cent" | "whole";
is_active: boolean;
};
export type EntityLink = {
entity_type: string;
entity_id: string;
};
export type Communication = {
id: string;
communication_type: string;
direction: string;
subject: string;
body: string;
status: string;
occurred_at?: string | null;
links: EntityLink[];
};
export type DocumentVersion = {
id: string;
version_no: number;
file_name: string;
content_type: string;
file_size: number;
checksum_sha256: string;
created_at: string;
};
export type DocumentRecord = {
id: string;
title: string;
description: string;
status: string;
latest_version?: DocumentVersion | null;
links: EntityLink[];
};
export type DocumentDownload = {
document_id: string;
version_id: string;
file_name: string;
content_type: string;
content_base64: string;
};
export type DocumentAuditLogEntry = {
id: string;
document_id: string;
version_id?: string | null;
action: string;
user_id?: string | null;
created_at: string;
};
export type LoginResponse = {
user_id: string;
access_token: string;
organization_id?: string | null;
must_change_password: boolean;
organizations: Array<{
id: string;
schema_name?: string | null;
status: string;
}>;
};
export type AuthSession = {
email: string;
userId: string;
accessToken: string;
organizationId?: string | null;
mustChangePassword: boolean;
};
export type NavigationMode = "scroll" | "groups";
export type UserNavigationSettings = {
mode: NavigationMode;
};
export type RecordSummary = {
id: string;
title: string;
updated_at: string;
};
export type ServerMessage =
| { type: "snapshot"; payload: { records: RecordSummary[] } }
| { type: "record_changed"; payload: { record: RecordSummary } }
| { type: "pong" }
| { type: "error"; payload: { message: string } };
export type ClientMessage =
| { type: "subscribe"; payload: { topic: string } }
| { type: "ping" };
export type WireMessage =
| { type: "hello"; payload: HelloMessage }
| { type: "hello_ack"; payload: HelloAckMessage }
| { type: "encrypted"; payload: EncryptedEnvelope }
| { type: "error"; payload: { message: string } };
export type HelloMessage = {
protocol_version: number;
key_id: string;
session_key: string;
};
export type HelloAckMessage = {
protocol_version: number;
key_id: string;
};
export type EncryptedEnvelope = {
enc: string;
key_id: string;
nonce: string;
ciphertext: string;
};
export type SessionCrypto = {
keyId: string;
key: CryptoKey;
exportedKey: string;
};
export type DevBootstrapResponse = {
organization_id: string;
schema_name: string;
user_id: string;
email: string;
password: string;
dev_mode: boolean;
};

View File

@@ -0,0 +1,32 @@
import { reactive } from "vue";
import { apiGet, apiPut } from "./api";
import type { NavigationMode, UserNavigationSettings } from "./types";
export const userSettings = reactive({
navigationMode: "scroll" as NavigationMode,
status: "",
loaded: false
});
export async function loadUserSettings() {
const result = await apiGet<UserNavigationSettings>("/api/v1/users/me/settings/navigation");
userSettings.loaded = true;
if (result.ok) {
userSettings.navigationMode = result.data.mode;
userSettings.status = "";
} else {
userSettings.status = result.message;
}
}
export async function saveNavigationMode(mode: NavigationMode) {
userSettings.navigationMode = mode;
userSettings.status = "Speichere Menü...";
const result = await apiPut<UserNavigationSettings>("/api/v1/users/me/settings/navigation", { mode });
if (result.ok) {
userSettings.navigationMode = result.data.mode;
userSettings.status = "Menü gespeichert.";
} else {
userSettings.status = result.message;
}
}

View File

@@ -0,0 +1,4 @@
export function formatDate(value?: string | null) {
if (!value) return "-";
return new Date(value).toLocaleString("de-DE");
}

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { apiPost } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
const router = useRouter();
const form = reactive({ token: "", new_password: "", new_password_confirm: "" });
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
async function submit() {
const result = await apiPost("/api/v1/auth/accept-invitation", form);
status.value = result.ok ? "Einladung angenommen. Anmeldung ist jetzt möglich." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) setTimeout(() => router.push("/login"), 800);
}
</script>
<template>
<PageHeader title="Einladung annehmen" description="Einladungstoken einlösen und eigenes Passwort setzen." />
<section class="panel form-panel">
<form @submit.prevent="submit">
<label class="field"><span>Einladungstoken</span><input v-model="form.token" required /></label>
<label class="field"><span>Passwort</span><input v-model="form.new_password" type="password" required /></label>
<label class="field"><span>Passwort wiederholen</span><input v-model="form.new_password_confirm" type="password" required /></label>
<div class="form-actions"><button type="submit">Einladung annehmen</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
</section>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Activity } from "../types";
const emptyForm = () => ({ activity_number: null as string | null, activity_type: "task", title: "", body: "", status: "open", priority: "normal", due_at: null as string | null });
const records = ref<Activity[]>([]); const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
const filteredRecords = computed(() =>
records.value.filter((record) => matchesObjectSearch(record.activity_number, record.title, search.value))
);
async function load() { const r = await apiGet<Activity[]>("/api/v1/activities"); if (r.ok) records.value = r.data; else { status.value = r.message; kind.value = "error"; } }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.activity_number = await reserveNextNumber("activities"); }
function select(record: Activity) { selectedId.value = record.id; Object.assign(form, record); }
async function save() { const r = selectedId.value ? await apiPut<Activity>(`/api/v1/activities/${selectedId.value}`, form) : await apiPost<Activity>("/api/v1/activities", form); status.value = r.ok ? "Aktivität gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await load(); } }
async function cancel() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/activities/${selectedId.value}`); status.value = r.ok ? "Aktivität storniert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) await load(); }
onMounted(load); watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Aktivitäten" description="Aufgaben, Wiedervorlagen und Gesprächsnotizen." />
<div class="workspace-split">
<section class="panel list-panel"><div class="section-title"><h2>Aktivitäten</h2><button type="button" @click="createNew">Neu</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Aktivitätsnummer oder Titel" /></label>
<button v-for="record in filteredRecords" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)"><strong>{{ record.activity_number ?? record.activity_type }}</strong><span>{{ record.title }}</span><small>{{ record.status }}</small></button>
<p v-if="records.length === 0" class="empty">Keine Aktivitäten vorhanden.</p>
<p v-else-if="filteredRecords.length === 0" class="empty">Keine Treffer.</p>
</section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
<label class="field"><span>Aktivitätsnummer</span><div class="readonly-value">{{ form.activity_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Typ</span><select v-model="form.activity_type"><option value="task">Aufgabe</option><option value="follow_up">Wiedervorlage</option><option value="phone_note">Telefonnotiz</option><option value="internal_note">Interne Notiz</option></select></label>
<label class="field"><span>Titel</span><input v-model="form.title" required /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="open">Offen</option><option value="in_progress">In Bearbeitung</option><option value="done">Erledigt</option><option value="cancelled">Storniert</option></select></label>
<label class="field"><span>Priorität</span><select v-model="form.priority"><option value="low">Niedrig</option><option value="normal">Normal</option><option value="high">Hoch</option><option value="critical">Kritisch</option></select></label>
<label class="field full-width"><span>Beschreibung</span><textarea v-model="form.body" rows="5" /></label>
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="cancel">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { apiGet, apiPost } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import type { OrganizationRegistrationDetail } from "../types";
import { formatDate } from "../utils";
const route = useRoute();
const detail = ref<OrganizationRegistrationDetail | null>(null);
const emptyMessage = ref("Details noch nicht geladen.");
const status = ref("");
const statusKind = ref<"info" | "success" | "error">("info");
const id = String(route.params.id ?? "");
onMounted(load);
async function load() {
const result = await apiGet<OrganizationRegistrationDetail>(`/api/v1/admin/organization-registrations/${encodeURIComponent(id)}`);
if (!result.ok) {
detail.value = null;
emptyMessage.value = result.message;
return;
}
detail.value = result.data;
}
async function runAction(action: "approve" | "reject" | "resend-initial-email" | "retry-provisioning") {
const result = await apiPost(`/api/v1/admin/organization-registrations/${encodeURIComponent(id)}/${action}`, {});
status.value = result.ok ? "Aktion ausgeführt." : result.message;
statusKind.value = result.ok ? "success" : "error";
if (result.ok) await load();
}
</script>
<template>
<PageHeader title="Registrierungsdetail" description="Prüfen, freischalten oder ablehnen." />
<section class="panel">
<div class="section-title">
<h2>Details</h2>
<button type="button" @click="load">Laden</button>
</div>
<p v-if="!detail" class="empty">{{ emptyMessage }}</p>
<dl v-else class="detail-grid">
<dt>Firma</dt><dd>{{ detail.organization_name ?? "-" }}</dd>
<dt>E-Mail</dt><dd>{{ detail.email }}</dd>
<dt>Status</dt><dd>{{ detail.status }}</dd>
<dt>Organization-ID</dt><dd><code>{{ detail.organization_id ?? "-" }}</code></dd>
<dt>Schema</dt><dd><code>{{ detail.schema_name ?? "-" }}</code></dd>
<dt>Angefragt</dt><dd>{{ formatDate(detail.requested_at) }}</dd>
<dt>Entschieden</dt><dd>{{ formatDate(detail.decided_at) }}</dd>
<dt>Provisioning</dt><dd>{{ detail.provisioning_error ?? "-" }}</dd>
</dl>
<div class="button-row">
<button type="button" @click="runAction('approve')">Freischalten</button>
<button type="button" class="secondary" @click="runAction('reject')">Ablehnen</button>
<button type="button" class="secondary" @click="runAction('resend-initial-email')">E-Mail erneut senden</button>
<button type="button" class="secondary" @click="runAction('retry-provisioning')">Schema-Erstellung wiederholen</button>
</div>
<FormStatus :message="status" :kind="statusKind" />
</section>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { apiGet } from "../api";
import PageHeader from "../components/PageHeader.vue";
import type { OrganizationRegistration } from "../types";
import { formatDate } from "../utils";
const registrations = ref<OrganizationRegistration[]>([]);
const status = ref("Noch keine Daten geladen.");
const loaded = ref(false);
const loading = ref(false);
async function load() {
loading.value = true;
status.value = "Lade Registrierungen...";
const result = await apiGet<OrganizationRegistration[]>("/api/v1/admin/organization-registrations");
loaded.value = true;
loading.value = false;
if (!result.ok) {
registrations.value = [];
status.value = result.message;
return;
}
registrations.value = result.data;
status.value = "Keine Registrierungen vorhanden.";
}
onMounted(load);
</script>
<template>
<PageHeader title="Freischaltungen" description="Offene und entschiedene Organization-Registrierungen." />
<section class="panel">
<div class="section-title">
<h2>Registrierungen</h2>
<button type="button" :disabled="loading" @click="load">
{{ loading ? "Lädt..." : "Aktualisieren" }}
</button>
</div>
<p v-if="!loaded || registrations.length === 0" class="empty">{{ status }}</p>
<div v-else class="data-table registrations">
<div class="table-head">Firma</div>
<div class="table-head">E-Mail</div>
<div class="table-head">Status</div>
<div class="table-head">Eingegangen</div>
<template v-for="item in registrations" :key="item.id">
<RouterLink :to="`/admin/organization-registrations/${encodeURIComponent(item.id)}`">
{{ item.organization_name ?? item.id }}
</RouterLink>
<div>{{ item.email }}</div>
<span class="status-pill">{{ item.status }}</span>
<time>{{ formatDate(item.requested_at) }}</time>
</template>
</div>
</section>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import type { ApiConnector } from "../types";
const connectors = ref<ApiConnector[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive({ code: "", name: "", connector_type: "generic_price_api", config: "{\n \"base_url\": \"https://example.invalid\",\n \"api_key\": \"dev\"\n}", is_active: true, sync_interval_minutes: 1440 as number | null });
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
async function load() { const result = await apiGet<ApiConnector[]>("/api/v1/api-connectors"); if (result.ok) connectors.value = result.data; else { status.value = result.message; kind.value = "error"; } }
function createNew() { selectedId.value = null; Object.assign(form, { code: "", name: "", connector_type: "generic_price_api", config: "{\n \"base_url\": \"https://example.invalid\",\n \"api_key\": \"dev\"\n}", is_active: true, sync_interval_minutes: 1440 }); }
function select(connector: ApiConnector) { selectedId.value = connector.id; Object.assign(form, { code: connector.code, name: connector.name, connector_type: connector.connector_type, config: JSON.stringify(connector.config, null, 2), is_active: connector.is_active, sync_interval_minutes: connector.sync_interval_minutes ?? null }); }
function payload() { return { ...form, config: JSON.parse(form.config || "{}") }; }
async function save() {
try {
const result = selectedId.value ? await apiPut<ApiConnector>(`/api/v1/api-connectors/${selectedId.value}`, payload()) : await apiPost<ApiConnector>("/api/v1/api-connectors", payload());
status.value = result.ok ? "Connector gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
} catch { status.value = "Konfiguration ist kein gültiges JSON."; kind.value = "error"; }
}
async function sync() { if (!selectedId.value) return; const result = await apiPost(`/api/v1/api-connectors/${selectedId.value}/sync`, {}); status.value = result.ok ? "Abgleich ausgeführt." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
async function deactivate() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/api-connectors/${selectedId.value}`); status.value = result.ok ? "Connector deaktiviert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
onMounted(load);
</script>
<template>
<PageHeader title="Preis-APIs" description="Externe Preisquellen konfigurieren und manuell abgleichen." />
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Connectoren</h2><button type="button" @click="createNew">Neu</button></div><button v-for="connector in connectors" :key="connector.id" type="button" class="list-row" :class="{ selected: selectedId === connector.id }" @click="select(connector)"><strong>{{ connector.code }}</strong><span>{{ connector.name }}</span><small>{{ connector.last_sync_at ?? "kein Abgleich" }}</small></button></section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"><label class="field"><span>Code</span><input v-model="form.code" required /></label><label class="field"><span>Name</span><input v-model="form.name" required /></label><label class="field"><span>Typ</span><input v-model="form.connector_type" required /></label><label class="field"><span>Intervall Minuten</span><input v-model="form.sync_interval_minutes" type="number" min="1" /></label><label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label><label class="field full-width"><span>Konfiguration JSON</span><textarea v-model="form.config" rows="8" /></label></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="sync">Abgleichen</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form></section></div>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm } from "../types";
const emptyForm = () => ({
code: "",
name: "",
discount_percent: "2",
discount_days: 10,
net_days: 30 as number | null,
valid_from: null as string | null,
valid_until: null as string | null,
is_default_customer_term: false,
is_default_supplier_term: false,
is_active: true
});
const terms = ref<CashDiscountTerm[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
async function load() {
const result = await apiGet<CashDiscountTerm[]>("/api/v1/cash-discount-terms");
if (result.ok) terms.value = result.data;
else { status.value = result.message; kind.value = "error"; }
}
function createNew() {
selectedId.value = null;
Object.assign(form, emptyForm());
}
function select(term: CashDiscountTerm) {
selectedId.value = term.id;
Object.assign(form, {
code: term.code,
name: term.name,
discount_percent: term.discount_percent,
discount_days: term.discount_days,
net_days: term.net_days ?? null,
valid_from: term.valid_from ?? null,
valid_until: term.valid_until ?? null,
is_default_customer_term: term.is_default_customer_term,
is_default_supplier_term: term.is_default_supplier_term,
is_active: term.is_active
});
}
async function save() {
const result = selectedId.value
? await apiPut<CashDiscountTerm>(`/api/v1/cash-discount-terms/${selectedId.value}`, form)
: await apiPost<CashDiscountTerm>("/api/v1/cash-discount-terms", form);
status.value = result.ok ? "Skonto-Regel gespeichert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function deactivate() {
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/cash-discount-terms/${selectedId.value}`);
status.value = result.ok ? "Skonto-Regel deaktiviert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) await load();
}
onMounted(load);
watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Skonto" description="Zahlungsbedingungen für Kunden, Lieferanten und Belege." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
<button v-for="term in terms" :key="term.id" type="button" class="list-row" :class="{ selected: selectedId === term.id }" @click="select(term)">
<strong>{{ term.code }}</strong>
<span>{{ term.name }}</span>
<small>{{ term.discount_percent }} % / {{ term.discount_days }} Tage</small>
</button>
<p v-if="terms.length === 0" class="empty">Keine Skonto-Regeln vorhanden.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save">
<div class="form-grid">
<label class="field"><span>Code</span><input v-model="form.code" required /></label>
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
<label class="field"><span>Skonto %</span><input v-model="form.discount_percent" type="number" min="0" max="100" step="0.01" required /></label>
<label class="field"><span>Skontofrist Tage</span><input v-model="form.discount_days" type="number" min="0" required /></label>
<label class="field"><span>Nettoziel Tage</span><input v-model="form.net_days" type="number" min="0" /></label>
<label class="field"><span>Gültig ab</span><input v-model="form.valid_from" type="date" /></label>
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" /></label>
<label class="check-row"><input v-model="form.is_default_customer_term" type="checkbox" /><span>Standard für Kunden</span></label>
<label class="check-row"><input v-model="form.is_default_supplier_term" type="checkbox" /><span>Standard für Lieferanten</span></label>
<label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
</div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
</section>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { apiPost } from "../api";
import { authState, updateAuthSession } from "../auth";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
const router = useRouter();
const form = reactive({
current_password: "",
new_password: "",
new_password_confirm: ""
});
const pending = ref(false);
const status = ref("");
const statusKind = ref<"info" | "success" | "error">("info");
async function submit() {
if (form.new_password !== form.new_password_confirm) {
status.value = "Die neuen Passwörter stimmen nicht überein.";
statusKind.value = "error";
return;
}
pending.value = true;
status.value = "Sende Anfrage...";
statusKind.value = "info";
const result = await apiPost("/api/v1/auth/change-initial-password", {
email: authState.session?.email ?? "",
...form
});
pending.value = false;
if (!result.ok) {
status.value = result.message;
statusKind.value = "error";
return;
}
updateAuthSession({ mustChangePassword: false });
router.push("/setup/organization");
}
</script>
<template>
<PageHeader title="Initialpasswort ändern" description="Nach dem ersten Login muss ein eigenes Passwort gesetzt werden." />
<section class="panel form-panel">
<form @submit.prevent="submit">
<label class="field">
<span>Aktuelles Passwort</span>
<input v-model="form.current_password" type="password" required />
</label>
<label class="field">
<span>Neues Passwort</span>
<input v-model="form.new_password" type="password" required />
</label>
<label class="field">
<span>Neues Passwort wiederholen</span>
<input v-model="form.new_password_confirm" type="password" required />
</label>
<div class="form-actions">
<button type="submit" :disabled="pending">Passwort speichern</button>
</div>
<FormStatus :message="status" :kind="statusKind" />
</form>
</section>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { liveUpdateState } from "../realtime";
import type { Communication, EntityLink } from "../types";
const emptyForm = () => ({
communication_type: "internal_note",
direction: "internal",
subject: "",
body: "",
status: "open",
occurred_at: null as string | null,
links: [] as EntityLink[]
});
const records = ref<Communication[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const linkType = ref("customer");
const linkId = ref("");
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
async function load() {
const result = await apiGet<Communication[]>("/api/v1/communications");
if (result.ok) records.value = result.data;
else { status.value = result.message; kind.value = "error"; }
}
function createNew() {
selectedId.value = null;
Object.assign(form, emptyForm());
}
function select(record: Communication) {
selectedId.value = record.id;
Object.assign(form, { ...record, links: [...record.links] });
}
function addLink() {
if (!linkId.value.trim()) return;
form.links.push({ entity_type: linkType.value, entity_id: linkId.value.trim() });
linkId.value = "";
}
function removeLink(index: number) {
form.links.splice(index, 1);
}
async function save() {
const result = selectedId.value
? await apiPut<Communication>(`/api/v1/communications/${selectedId.value}`, form)
: await apiPost<Communication>("/api/v1/communications", form);
status.value = result.ok ? "Kommunikation gespeichert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function archiveRecord() {
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/communications/${selectedId.value}`);
status.value = result.ok ? "Kommunikation archiviert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) await load();
}
onMounted(load);
watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Kommunikation" description="E-Mails, Telefonate, Briefe, Besprechungen und interne Notizen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Verlauf</h2><button type="button" @click="createNew">Neu</button></div>
<button v-for="record in records" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)">
<strong>{{ record.subject }}</strong>
<span>{{ record.communication_type }} · {{ record.direction }}</span>
<small>{{ record.status }}</small>
</button>
<p v-if="records.length === 0" class="empty">Keine Kommunikation vorhanden.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save">
<div class="form-grid">
<label class="field"><span>Typ</span><select v-model="form.communication_type"><option value="email">E-Mail</option><option value="phone">Telefon</option><option value="letter">Brief</option><option value="meeting">Besprechung</option><option value="internal_note">Interne Notiz</option></select></label>
<label class="field"><span>Richtung</span><select v-model="form.direction"><option value="inbound">Eingehend</option><option value="outbound">Ausgehend</option><option value="internal">Intern</option></select></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="open">Offen</option><option value="done">Erledigt</option><option value="archived">Archiviert</option></select></label>
<label class="field"><span>Zeitpunkt</span><input v-model="form.occurred_at" placeholder="optional, ISO-Zeit" /></label>
<label class="field full-width"><span>Betreff</span><input v-model="form.subject" required /></label>
<label class="field full-width"><span>Inhalt</span><textarea v-model="form.body" rows="8" /></label>
</div>
<div class="sub-panel">
<h2>Bezüge</h2>
<div class="form-grid">
<label class="field"><span>Typ</span><select v-model="linkType"><option value="customer">Kunde</option><option value="supplier">Lieferant</option><option value="activity">Aktivität</option><option value="quote">Angebot</option><option value="outgoing_invoice">Ausgangsrechnung</option><option value="incoming_invoice">Eingangsrechnung</option><option value="item">Artikel</option><option value="document">Dokument</option></select></label>
<label class="field"><span>ID</span><input v-model="linkId" /></label>
</div>
<button type="button" class="secondary" @click="addLink">Bezug hinzufügen</button>
<div v-for="(link, index) in form.links" :key="`${link.entity_type}-${link.entity_id}`" class="data-row">
<strong>{{ link.entity_type }}</strong><span>{{ link.entity_id }}</span><button type="button" class="secondary" @click="removeLink(index)">Entfernen</button>
</div>
</div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="archiveRecord">Archivieren</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
</section>
</div>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm, Customer } from "../types";
const emptyCustomer = () => ({
customer_number: "",
name: "",
status: "active",
details: {
street: "",
postal_code: "",
city: "",
country: "Deutschland",
email: "",
phone: ""
},
standard_discount_percent: "0",
cash_discount_term_id: null as string | null
});
const customers = ref<Customer[]>([]);
const cashDiscountTerms = ref<CashDiscountTerm[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyCustomer());
const status = ref("");
const statusKind = ref<"info" | "success" | "error">("info");
const pending = ref(false);
const search = ref("");
const filteredCustomers = computed(() =>
customers.value.filter((customer) => matchesObjectSearch(customer.customer_number, customer.name, search.value))
);
async function loadCustomers() {
const result = await apiGet<Customer[]>("/api/v1/customers");
if (!result.ok) {
status.value = result.message;
statusKind.value = "error";
return;
}
customers.value = result.data;
}
async function loadCashDiscountTerms() {
const result = await apiGet<CashDiscountTerm[]>("/api/v1/cash-discount-terms");
if (result.ok) cashDiscountTerms.value = result.data.filter((term) => term.is_active);
}
async function newCustomer() {
selectedId.value = null;
Object.assign(form, emptyCustomer());
form.details = { ...emptyCustomer().details };
form.customer_number = (await reserveNextNumber("customers")) ?? "";
status.value = "Neuer Kunde.";
statusKind.value = "info";
}
function selectCustomer(customer: Customer) {
selectedId.value = customer.id;
Object.assign(form, {
customer_number: customer.customer_number,
name: customer.name,
status: customer.status,
details: { ...customer.details },
standard_discount_percent: customer.standard_discount_percent,
cash_discount_term_id: customer.cash_discount_term_id ?? null
});
}
async function save() {
pending.value = true;
const result = selectedId.value
? await apiPut<Customer>(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`, form)
: await apiPost<Customer>("/api/v1/customers", form);
pending.value = false;
if (!result.ok) {
status.value = result.message;
statusKind.value = "error";
return;
}
selectedId.value = result.data.id;
status.value = "Kunde gespeichert.";
statusKind.value = "success";
await loadCustomers();
}
async function deactivate() {
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/customers/${encodeURIComponent(selectedId.value)}`);
status.value = result.ok ? "Kunde deaktiviert." : result.message;
statusKind.value = result.ok ? "success" : "error";
if (result.ok) {
form.status = "inactive";
await loadCustomers();
}
}
onMounted(async () => {
await Promise.all([loadCustomers(), loadCashDiscountTerms()]);
});
watch(
() => liveUpdateState.revision,
() => Promise.all([loadCustomers(), loadCashDiscountTerms()])
);
</script>
<template>
<PageHeader title="Kunden" description="Kundenstamm, Rabatt und Kontaktdaten." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title">
<h2>Kundenliste</h2>
<button type="button" @click="newCustomer">Neu</button>
</div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Kundennummer oder Name" /></label>
<button
v-for="customer in filteredCustomers"
:key="customer.id"
type="button"
class="list-row"
:class="{ selected: selectedId === customer.id }"
@click="selectCustomer(customer)"
>
<strong>{{ customer.customer_number }}</strong>
<span>{{ customer.name }}</span>
<small>{{ customer.status }}</small>
</button>
<p v-if="customers.length === 0" class="empty">Keine Kunden vorhanden.</p>
<p v-else-if="filteredCustomers.length === 0" class="empty">Keine Treffer.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save">
<div class="form-grid">
<label class="field"><span>Kundennummer</span><div class="readonly-value">{{ form.customer_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
<label class="field">
<span>Status</span>
<select v-model="form.status">
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="blocked">Gesperrt</option>
</select>
</label>
<label class="field"><span>Standardrabatt %</span><input v-model="form.standard_discount_percent" type="number" min="0" max="100" step="0.01" required /></label>
<label class="field">
<span>Skonto-Regel</span>
<select v-model="form.cash_discount_term_id">
<option :value="null">Keine</option>
<option v-for="term in cashDiscountTerms" :key="term.id" :value="term.id">{{ term.code }} - {{ term.name }}</option>
</select>
</label>
<label class="field"><span>Straße</span><input v-model="form.details.street" /></label>
<label class="field"><span>PLZ</span><input v-model="form.details.postal_code" /></label>
<label class="field"><span>Ort</span><input v-model="form.details.city" /></label>
<label class="field"><span>Land</span><input v-model="form.details.country" /></label>
<label class="field"><span>E-Mail</span><input v-model="form.details.email" type="email" /></label>
<label class="field"><span>Telefon</span><input v-model="form.details.phone" /></label>
</div>
<div class="form-actions">
<button type="submit" :disabled="pending">Speichern</button>
<button v-if="selectedId" type="button" class="secondary" :disabled="pending" @click="deactivate">Deaktivieren</button>
</div>
<FormStatus :message="status" :kind="statusKind" />
</form>
</section>
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import PageHeader from "../components/PageHeader.vue";
import { connectionState, wsUrl } from "../realtime";
</script>
<template>
<PageHeader title="Dashboard" description="Übersicht über Backend-Verbindung und aktuelle Vorgänge." />
<section class="panel compact">
<div class="split-row">
<div>
<h2>Live-Verbindung</h2>
<p class="muted">{{ connectionState.status }}</p>
</div>
<code>{{ wsUrl }}</code>
</div>
</section>
<section class="panel">
<div class="section-title">
<h2>Aktuelle Datensätze</h2>
<span>{{ connectionState.records.length }}</span>
</div>
<p v-if="connectionState.records.length === 0" class="empty">Keine Datensätze vorhanden.</p>
<div v-else class="data-table two-cols">
<div class="table-head">Titel</div>
<div class="table-head">Aktualisiert</div>
<template v-for="record in connectionState.records" :key="record.id">
<div>{{ record.title }}</div>
<time>{{ new Date(record.updated_at).toLocaleString("de-DE") }}</time>
</template>
</div>
</section>
</template>

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { liveUpdateState } from "../realtime";
import type { DocumentAuditLogEntry, DocumentDownload, DocumentRecord, EntityLink } from "../types";
const documents = ref<DocumentRecord[]>([]);
const auditLog = ref<DocumentAuditLogEntry[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive({ title: "", description: "", file_name: "", content_type: "text/plain", content_base64: "", links: [] as EntityLink[] });
const linkType = ref("customer");
const linkId = ref("");
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
async function load() {
const result = await apiGet<DocumentRecord[]>("/api/v1/documents");
if (result.ok) documents.value = result.data;
else { status.value = result.message; kind.value = "error"; }
}
function createNew() {
selectedId.value = null;
Object.assign(form, { title: "", description: "", file_name: "", content_type: "text/plain", content_base64: "", links: [] });
auditLog.value = [];
}
async function select(record: DocumentRecord) {
selectedId.value = record.id;
Object.assign(form, {
title: record.title,
description: record.description,
file_name: record.latest_version?.file_name ?? "",
content_type: record.latest_version?.content_type ?? "text/plain",
content_base64: "",
links: [...record.links]
});
await loadAuditLog(record.id);
}
async function onFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
form.file_name = file.name;
form.content_type = file.type || "application/octet-stream";
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
form.content_base64 = btoa(binary);
}
function addLink() {
if (!linkId.value.trim()) return;
form.links.push({ entity_type: linkType.value, entity_id: linkId.value.trim() });
linkId.value = "";
}
function removeLink(index: number) {
form.links.splice(index, 1);
}
async function upload() {
const result = await apiPost<DocumentRecord>("/api/v1/documents", form);
status.value = result.ok ? "Dokument hochgeladen." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); await loadAuditLog(result.data.id); }
}
async function download() {
if (!selectedId.value) return;
const result = await apiGet<DocumentDownload>(`/api/v1/documents/${selectedId.value}/download`);
if (!result.ok) { status.value = result.message; kind.value = "error"; return; }
const bytes = Uint8Array.from(atob(result.data.content_base64), (char) => char.charCodeAt(0));
const blob = new Blob([bytes], { type: result.data.content_type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = result.data.file_name;
link.click();
URL.revokeObjectURL(url);
status.value = "Download vorbereitet.";
kind.value = "success";
await loadAuditLog(selectedId.value);
}
async function archiveDocument() {
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/documents/${selectedId.value}`);
status.value = result.ok ? "Dokument archiviert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) await load();
}
async function loadAuditLog(documentId: string) {
const result = await apiGet<DocumentAuditLogEntry[]>(`/api/v1/documents/${documentId}/audit-log`);
if (result.ok) auditLog.value = result.data;
}
onMounted(load);
watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Dokumente" description="Dokumente verschlüsselt ablegen, zuordnen und herunterladen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Dokumente</h2><button type="button" @click="createNew">Neu</button></div>
<button v-for="record in documents" :key="record.id" type="button" class="list-row" :class="{ selected: selectedId === record.id }" @click="select(record)">
<strong>{{ record.title }}</strong>
<span>{{ record.latest_version?.file_name ?? "ohne Datei" }}</span>
<small>{{ record.status }}</small>
</button>
<p v-if="documents.length === 0" class="empty">Keine Dokumente vorhanden.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="upload">
<div class="form-grid">
<label class="field"><span>Titel</span><input v-model="form.title" required /></label>
<label class="field"><span>Content-Type</span><input v-model="form.content_type" required /></label>
<label class="field full-width"><span>Beschreibung</span><textarea v-model="form.description" rows="4" /></label>
<label class="field full-width"><span>Datei</span><input type="file" @change="onFileChange" /></label>
<label class="field full-width"><span>Dateiname</span><input v-model="form.file_name" required /></label>
</div>
<div class="sub-panel">
<h2>Bezüge</h2>
<div class="form-grid">
<label class="field"><span>Typ</span><select v-model="linkType"><option value="customer">Kunde</option><option value="supplier">Lieferant</option><option value="activity">Aktivität</option><option value="communication">Kommunikation</option><option value="quote">Angebot</option><option value="outgoing_invoice">Ausgangsrechnung</option><option value="incoming_invoice">Eingangsrechnung</option><option value="item">Artikel</option></select></label>
<label class="field"><span>ID</span><input v-model="linkId" /></label>
</div>
<button type="button" class="secondary" @click="addLink">Bezug hinzufügen</button>
<div v-for="(link, index) in form.links" :key="`${link.entity_type}-${link.entity_id}`" class="data-row">
<strong>{{ link.entity_type }}</strong><span>{{ link.entity_id }}</span><button type="button" class="secondary" @click="removeLink(index)">Entfernen</button>
</div>
</div>
<div class="form-actions"><button type="submit">Hochladen</button><button v-if="selectedId" type="button" class="secondary" @click="download">Herunterladen</button><button v-if="selectedId" type="button" class="secondary" @click="archiveDocument">Archivieren</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
<div v-if="auditLog.length > 0" class="sub-panel">
<h2>Audit-Log</h2>
<div v-for="entry in auditLog" :key="entry.id" class="data-row"><strong>{{ entry.action }}</strong><span>{{ new Date(entry.created_at).toLocaleString("de-DE") }}</span><small>{{ entry.user_id ?? "-" }}</small></div>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { IncomingInvoice, IncomingInvoiceItem, Item, Supplier } from "../types";
const emptyItem = (): IncomingInvoiceItem => ({ item_id: null, description: "", quantity: "1", unit_price: "0", tax_rate: "19" });
const emptyForm = () => ({ invoice_number: "", supplier_id: "", status: "received", cash_discount_term_id: null as string | null, invoice_date: null as string | null, due_at: null as string | null, items: [emptyItem()] });
const invoices = ref<IncomingInvoice[]>([]); const suppliers = ref<Supplier[]>([]); const items = ref<Item[]>([]);
const selectedId = ref<string | null>(null); const form = reactive(emptyForm()); const status = ref(""); const kind = ref<"info" | "success" | "error">("info"); const search = ref("");
const supplierName = (supplierId: string) => suppliers.value.find((supplier) => supplier.id === supplierId)?.name ?? supplierId;
const filteredInvoices = computed(() => {
const needle = search.value.trim().toLowerCase();
if (!needle) return invoices.value;
return invoices.value.filter((invoice) =>
`${invoice.invoice_number} ${supplierName(invoice.supplier_id)} ${invoice.status}`.toLowerCase().includes(needle)
);
});
async function load() { const [ir, sr, itemr] = await Promise.all([apiGet<IncomingInvoice[]>("/api/v1/incoming-invoices"), apiGet<Supplier[]>("/api/v1/suppliers"), apiGet<Item[]>("/api/v1/items")]); if (ir.ok) invoices.value = ir.data; else { status.value = ir.message; kind.value = "error"; } if (sr.ok) suppliers.value = sr.data.filter((supplier) => supplier.status === "active"); if (itemr.ok) items.value = itemr.data.filter((item) => item.status === "active"); }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.invoice_number = (await reserveNextNumber("incoming_invoices")) ?? ""; }
function select(invoice: IncomingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item })) }); }
function addLine() { form.items.push(emptyItem()); }
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); }
async function save() { const result = selectedId.value ? await apiPut<IncomingInvoice>(`/api/v1/incoming-invoices/${selectedId.value}`, form) : await apiPost<IncomingInvoice>("/api/v1/incoming-invoices", form); status.value = result.ok ? "Eingangsrechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); } }
async function cancelInvoice() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/incoming-invoices/${selectedId.value}`); status.value = result.ok ? "Eingangsrechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
onMounted(load); watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Eingangsrechnungen" description="Lieferantenrechnungen mit Skonto-Bezug und Positionen." />
<div class="workspace-split"><section class="panel list-panel"><div class="section-title"><h2>Eingangsrechnungen</h2><button type="button" @click="createNew">Neu</button></div><label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Lieferant oder Status" /></label><button v-for="invoice in filteredInvoices" :key="invoice.id" type="button" class="list-row" :class="{ selected: selectedId === invoice.id }" @click="select(invoice)"><strong>{{ invoice.invoice_number }}</strong><span>{{ supplierName(invoice.supplier_id) }}</span><small>{{ invoice.status }}</small></button><p v-if="invoices.length === 0" class="empty">Keine Eingangsrechnungen vorhanden.</p><p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p></section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid"><label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label><label class="field"><span>Lieferant</span><SearchSelect v-model="form.supplier_id" :options="suppliers.map((supplier) => ({ id: supplier.id, number: supplier.supplier_number, name: supplier.name }))" placeholder="Lieferanten-Nr. oder Name suchen" required /></label><label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="received">Erhalten</option><option value="approved">Freigegeben</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label><label class="field"><span>Datum</span><input v-model="form.invoice_date" type="date" /></label><label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label></div>
<div class="sub-panel"><div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div><div v-for="(line, index) in form.items" :key="index" class="quote-line"><label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" allow-empty empty-label="Kein Artikel" /></label><label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" /></label><label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button><label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label></div></div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section></div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Item, ItemPriceHistory } from "../types";
const emptyForm = () => ({ item_number: "", name: "", unit: "Stk", tax_rate: "19", default_purchase_price: null as string | null, default_sales_price: null as string | null, status: "active" });
const items = ref<Item[]>([]);
const priceHistory = ref<ItemPriceHistory[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
const search = ref("");
const filteredItems = computed(() =>
items.value.filter((item) => matchesObjectSearch(item.item_number, item.name, search.value))
);
async function load() { const r = await apiGet<Item[]>("/api/v1/items"); if (r.ok) items.value = r.data; else { status.value = r.message; kind.value = "error"; } }
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.item_number = (await reserveNextNumber("items")) ?? ""; priceHistory.value = []; }
async function select(item: Item) { selectedId.value = item.id; Object.assign(form, item); await loadPriceHistory(item.id); }
async function loadPriceHistory(itemId: string) {
const result = await apiGet<ItemPriceHistory[]>(`/api/v1/items/${itemId}/prices`);
if (result.ok) priceHistory.value = result.data;
}
async function save() {
const r = selectedId.value ? await apiPut<Item>(`/api/v1/items/${selectedId.value}`, form) : await apiPost<Item>("/api/v1/items", form);
status.value = r.ok ? "Artikel gespeichert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) { selectedId.value = r.data.id; await Promise.all([load(), loadPriceHistory(r.data.id)]); }
}
async function deactivate() { if (!selectedId.value) return; const r = await apiDelete(`/api/v1/items/${selectedId.value}`); status.value = r.ok ? "Artikel deaktiviert." : r.message; kind.value = r.ok ? "success" : "error"; if (r.ok) await load(); }
onMounted(load); watch(() => liveUpdateState.revision, async () => { await load(); if (selectedId.value) await loadPriceHistory(selectedId.value); });
</script>
<template>
<PageHeader title="Artikel" description="Artikelstamm und aktuelle Standardpreise." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Artikel</h2><button type="button" @click="createNew">Neu</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Artikelnummer oder Bezeichnung" /></label>
<button v-for="item in filteredItems" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)"><strong>{{ item.item_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small></button>
<p v-if="items.length === 0" class="empty">Keine Artikel vorhanden.</p>
<p v-else-if="filteredItems.length === 0" class="empty">Keine Treffer.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save"><div class="form-grid">
<label class="field"><span>Artikelnummer</span><div class="readonly-value">{{ form.item_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Bezeichnung</span><input v-model="form.name" required /></label>
<label class="field"><span>Einheit</span><input v-model="form.unit" required /></label>
<label class="field"><span>Steuersatz %</span><input v-model="form.tax_rate" type="number" min="0" step="0.01" required /></label>
<label class="field"><span>Einkaufspreis</span><input v-model="form.default_purchase_price" type="number" min="0" step="0.01" /></label>
<label class="field"><span>Verkaufspreis</span><input v-model="form.default_sales_price" type="number" min="0" step="0.01" /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
</div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div><FormStatus :message="status" :kind="kind" /></form>
<div v-if="selectedId" class="sub-panel">
<h2>Preishistorie</h2>
<div v-for="entry in priceHistory" :key="entry.id" class="data-row">
<strong>{{ new Date(entry.valid_from).toLocaleString("de-DE") }}</strong>
<span>EK {{ entry.purchase_price ?? "-" }}</span>
<span>VK {{ entry.sales_price ?? "-" }}</span>
<small>{{ entry.source }}</small>
</div>
<p v-if="priceHistory.length === 0" class="empty">Noch keine Preisänderung vorhanden.</p>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { apiPost } from "../api";
import { setAuthSession } from "../auth";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { ensureConnection } from "../realtime";
import type { DevBootstrapResponse, LoginResponse } from "../types";
const router = useRouter();
const form = reactive({
email: "",
password: ""
});
const pending = ref(false);
const status = ref("");
const statusKind = ref<"info" | "success" | "error">("info");
const devForm = reactive({
organization_name: "Lokale Testfirma",
email: "admin@example.test"
});
const devPending = ref(false);
const devStatus = ref("");
const devStatusKind = ref<"info" | "success" | "error">("info");
const devCredentials = ref<DevBootstrapResponse | null>(null);
async function submit() {
pending.value = true;
status.value = "Sende Anfrage...";
statusKind.value = "info";
const result = await apiPost<LoginResponse>("/api/v1/auth/login", form);
pending.value = false;
if (!result.ok) {
status.value = result.message;
statusKind.value = "error";
return;
}
setAuthSession({
email: form.email,
userId: result.data.user_id,
accessToken: result.data.access_token,
organizationId: result.data.organization_id ?? result.data.organizations[0]?.id ?? null,
mustChangePassword: result.data.must_change_password
});
ensureConnection();
router.push(result.data.must_change_password ? "/change-initial-password" : "/");
}
async function bootstrapDev() {
devPending.value = true;
devStatus.value = "Lege lokale Testfirma an...";
devStatusKind.value = "info";
devCredentials.value = null;
const result = await apiPost<DevBootstrapResponse>("/api/v1/dev/bootstrap-local", devForm);
devPending.value = false;
if (!result.ok) {
devStatus.value = result.message;
devStatusKind.value = "error";
return;
}
devCredentials.value = result.data;
form.email = result.data.email;
form.password = result.data.password;
devStatus.value = "Testfirma und User wurden angelegt.";
devStatusKind.value = "success";
}
</script>
<template>
<PageHeader title="Login" description="Anmeldung mit E-Mail-Adresse und Passwort." />
<section class="panel form-panel">
<form @submit.prevent="submit">
<label class="field">
<span>E-Mail-Adresse</span>
<input v-model="form.email" name="email" type="email" placeholder="admin@example.com" required />
</label>
<label class="field">
<span>Passwort</span>
<input v-model="form.password" name="password" type="password" required />
</label>
<div class="form-actions">
<button type="submit" :disabled="pending">Einloggen</button>
</div>
<FormStatus :message="status" :kind="statusKind" />
</form>
</section>
<section class="panel form-panel">
<div class="section-title">
<div>
<h2>Dev-Bootstrap</h2>
<p class="muted">Lokale Testfirma mit erstem User anlegen, ohne E-Mail-Versand.</p>
</div>
</div>
<form @submit.prevent="bootstrapDev">
<label class="field">
<span>Firmenname</span>
<input v-model="devForm.organization_name" type="text" required />
</label>
<label class="field">
<span>E-Mail-Adresse</span>
<input v-model="devForm.email" type="email" required />
</label>
<div class="form-actions">
<button type="submit" :disabled="devPending">Testfirma und User anlegen</button>
</div>
<FormStatus :message="devStatus" :kind="devStatusKind" />
</form>
<dl v-if="devCredentials" class="detail-grid dev-credentials">
<dt>Login</dt><dd>{{ devCredentials.email }}</dd>
<dt>Passwort</dt><dd><code>{{ devCredentials.password }}</code></dd>
<dt>Firma</dt><dd><code>{{ devCredentials.organization_id }}</code></dd>
<dt>Schema</dt><dd><code>{{ devCredentials.schema_name }}</code></dd>
</dl>
</section>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { apiGet, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { liveUpdateState } from "../realtime";
import type { NumberRange } from "../types";
const labels: Record<string, string> = {
items: "Artikel",
activities: "Aktivitäten",
outgoing_invoices: "Rechnungen",
incoming_invoices: "Eingangsrechnungen",
customers: "Kunden",
suppliers: "Lieferanten",
quotes: "Angebote"
};
const ranges = ref<NumberRange[]>([]);
const selectedCode = ref<string | null>(null);
const form = reactive({
pattern: "",
counter_value: 0,
counter_padding: 9,
reset_rule: null as string | null,
is_active: true
});
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
async function load() {
const result = await apiGet<NumberRange[]>("/api/v1/number-ranges");
if (!result.ok) {
status.value = result.message;
kind.value = "error";
return;
}
ranges.value = result.data;
if (!selectedCode.value && ranges.value[0]) select(ranges.value[0]);
}
function select(range: NumberRange) {
selectedCode.value = range.code;
Object.assign(form, {
pattern: range.pattern,
counter_value: range.counter_value,
counter_padding: range.counter_padding,
reset_rule: range.reset_rule ?? null,
is_active: range.is_active
});
}
async function save() {
if (!selectedCode.value) return;
const result = await apiPut<NumberRange>(`/api/v1/number-ranges/${selectedCode.value}`, form);
status.value = result.ok ? "Nummernkreis gespeichert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) await load();
}
onMounted(load);
watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Nummernkreise" description="Individuelle Nummern für Stammdaten, Angebote und Rechnungen." />
<div class="workspace-split">
<section class="panel list-panel">
<h2>Bereiche</h2>
<button v-for="range in ranges" :key="range.code" type="button" class="list-row" :class="{ selected: selectedCode === range.code }" @click="select(range)">
<strong>{{ labels[range.code] ?? range.code }}</strong>
<span>{{ range.pattern }}</span>
<small>Zähler {{ range.counter_value }}</small>
</button>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save">
<div class="form-grid">
<label class="field"><span>Muster</span><input v-model="form.pattern" required /></label>
<label class="field"><span>Zählerstand</span><input v-model="form.counter_value" type="number" min="0" required /></label>
<label class="field"><span>Zählerlänge</span><input v-model="form.counter_padding" type="number" min="1" max="18" required /></label>
<label class="field"><span>Reset-Regel</span><input v-model="form.reset_rule" /></label>
<label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
</div>
<p class="muted">Das Muster muss <code>{counter}</code> enthalten. Der Zähler wird bei 9 Stellen als 000.000.001 formatiert.</p>
<div class="form-actions"><button type="submit" :disabled="!selectedCode">Speichern</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
</section>
</div>
</template>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { apiGet, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { liveUpdateState } from "../realtime";
type OrganizationSetupForm = {
display_name: string;
legal_form: string;
street: string;
postal_code: string;
city: string;
country: string;
vat_id: string;
email: string;
phone: string;
default_tax_rate: string;
default_payment_days: string;
};
type OrganizationSetupResponse = {
organization_id: string;
schema_name: string;
setup: OrganizationSetupForm | null;
};
const form = reactive<OrganizationSetupForm>({
display_name: "",
legal_form: "",
street: "",
postal_code: "",
city: "",
country: "Deutschland",
vat_id: "",
email: "",
phone: "",
default_tax_rate: "19",
default_payment_days: "14"
});
const pending = ref(false);
const status = ref("");
const statusKind = ref<"info" | "success" | "error">("info");
const loaded = ref(false);
async function load() {
pending.value = true;
status.value = "Lade Firmendaten...";
statusKind.value = "info";
const result = await apiGet<OrganizationSetupResponse>("/api/v1/organizations/current/setup");
pending.value = false;
if (!result.ok) {
status.value = result.message;
statusKind.value = "error";
return;
}
if (result.data.setup) {
Object.assign(form, result.data.setup);
status.value = "Firmendaten geladen.";
statusKind.value = "info";
loaded.value = true;
return;
}
status.value = "Noch keine Firmendaten gespeichert.";
statusKind.value = "info";
loaded.value = true;
}
async function submit() {
pending.value = true;
status.value = "Sende Anfrage...";
statusKind.value = "info";
const result = await apiPut("/api/v1/organizations/current/setup", form);
pending.value = false;
status.value = result.ok ? "Gespeichert." : result.message;
statusKind.value = result.ok ? "success" : "error";
}
onMounted(load);
watch(
() => liveUpdateState.revision,
() => {
if (loaded.value) load();
}
);
</script>
<template>
<PageHeader title="Firmendaten" description="Stammdaten der aktiven Organization erfassen." />
<section class="panel form-panel wide">
<form @submit.prevent="submit">
<div class="form-grid">
<label class="field"><span>Firmenname</span><input v-model="form.display_name" type="text" placeholder="Muster GmbH" required /></label>
<label class="field"><span>Rechtsform</span><input v-model="form.legal_form" type="text" placeholder="GmbH" /></label>
<label class="field"><span>Straße und Hausnummer</span><input v-model="form.street" type="text" required /></label>
<label class="field"><span>PLZ</span><input v-model="form.postal_code" type="text" required /></label>
<label class="field"><span>Ort</span><input v-model="form.city" type="text" required /></label>
<label class="field"><span>Land</span><input v-model="form.country" type="text" required /></label>
<label class="field"><span>USt-IdNr.</span><input v-model="form.vat_id" type="text" /></label>
<label class="field"><span>E-Mail der Firma</span><input v-model="form.email" type="email" placeholder="info@example.com" required /></label>
<label class="field"><span>Telefon</span><input v-model="form.phone" type="tel" /></label>
<label class="field"><span>Standard-Steuersatz</span><input v-model="form.default_tax_rate" type="number" required /></label>
<label class="field"><span>Standard-Zahlungsziel</span><input v-model="form.default_payment_days" type="number" required /></label>
</div>
<div class="form-actions">
<button type="submit" :disabled="pending">Firmendaten speichern</button>
<button type="button" class="secondary" :disabled="pending" @click="load">Neu laden</button>
</div>
<FormStatus :message="status" :kind="statusKind" />
</form>
</section>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Customer, Item, OutgoingInvoice, OutgoingInvoiceItem, Quote } from "../types";
const emptyItem = (): OutgoingInvoiceItem => ({ item_id: "", description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
const emptyForm = () => ({ invoice_number: "", customer_id: "", status: "draft", cash_discount_term_id: null as string | null, customer_discount_percent: "0", issued_at: null as string | null, due_at: null as string | null, source_quote_id: null as string | null, items: [emptyItem()] });
const invoices = ref<OutgoingInvoice[]>([]);
const customers = ref<Customer[]>([]);
const items = ref<Item[]>([]);
const quotes = ref<Quote[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
const search = ref("");
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
const filteredInvoices = computed(() => {
const needle = search.value.trim().toLowerCase();
if (!needle) return invoices.value;
return invoices.value.filter((invoice) =>
`${invoice.invoice_number} ${customerName(invoice.customer_id)} ${invoice.status}`.toLowerCase().includes(needle)
);
});
async function load() {
const [invoiceResult, customerResult, itemResult, quoteResult] = await Promise.all([
apiGet<OutgoingInvoice[]>("/api/v1/outgoing-invoices"), apiGet<Customer[]>("/api/v1/customers"), apiGet<Item[]>("/api/v1/items"), apiGet<Quote[]>("/api/v1/quotes")
]);
if (invoiceResult.ok) invoices.value = invoiceResult.data; else { status.value = invoiceResult.message; kind.value = "error"; }
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
if (quoteResult.ok) quotes.value = quoteResult.data;
}
async function createNew() { selectedId.value = null; Object.assign(form, emptyForm()); form.invoice_number = (await reserveNextNumber("outgoing_invoices")) ?? ""; }
function select(invoice: OutgoingInvoice) { selectedId.value = invoice.id; Object.assign(form, { ...invoice, items: invoice.items.map((item) => ({ ...item })) }); }
function addLine() { form.items.push(emptyItem()); }
function removeLine(index: number) { if (form.items.length > 1) form.items.splice(index, 1); }
function applyItemDefaults(line: OutgoingInvoiceItem) {
const item = items.value.find((record) => record.id === line.item_id);
if (!item) return;
line.description = item.name; line.unit_price = item.default_sales_price ?? "0"; line.original_unit_price = item.default_sales_price ?? null; line.tax_rate = item.tax_rate;
}
async function save() {
const result = selectedId.value ? await apiPut<OutgoingInvoice>(`/api/v1/outgoing-invoices/${selectedId.value}`, form) : await apiPost<OutgoingInvoice>("/api/v1/outgoing-invoices", form);
status.value = result.ok ? "Rechnung gespeichert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function finalize() { if (!selectedId.value) return; const result = await apiPost(`/api/v1/outgoing-invoices/${selectedId.value}/finalize`, {}); status.value = result.ok ? "Rechnung abgeschlossen." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
async function cancelInvoice() { if (!selectedId.value) return; const result = await apiDelete(`/api/v1/outgoing-invoices/${selectedId.value}`); status.value = result.ok ? "Rechnung storniert." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) await load(); }
async function convertQuote(quoteId: string) { const result = await apiPost<OutgoingInvoice>(`/api/v1/quotes/${quoteId}/convert-to-invoice`, {}); status.value = result.ok ? "Angebot umgewandelt." : result.message; kind.value = result.ok ? "success" : "error"; if (result.ok) { selectedId.value = result.data.id; await load(); } }
onMounted(load); watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Ausgangsrechnungen" description="Rechnungen erstellen, aus Angeboten übernehmen und abschließen." />
<div class="workspace-split">
<section class="panel list-panel"><div class="section-title"><h2>Rechnungen</h2><button type="button" @click="createNew">Neu</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Rechnungsnummer, Kunde oder Status" /></label>
<button v-for="invoice in filteredInvoices" :key="invoice.id" type="button" class="list-row" :class="{ selected: selectedId === invoice.id }" @click="select(invoice)"><strong>{{ invoice.invoice_number }}</strong><span>{{ customerName(invoice.customer_id) }}</span><small>{{ invoice.status }}</small></button>
<p v-if="invoices.length === 0" class="empty">Keine Rechnungen vorhanden.</p>
<p v-else-if="filteredInvoices.length === 0" class="empty">Keine Treffer.</p>
<div class="sub-panel"><h2>Aus Angebot</h2><button v-for="quote in quotes" :key="quote.id" type="button" class="secondary" @click="convertQuote(quote.id)">{{ quote.quote_number }}</button></div>
</section>
<section class="panel detail-panel"><form @submit.prevent="save"><div class="form-grid">
<label class="field"><span>Rechnungsnummer</span><div class="readonly-value">{{ form.invoice_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="finalized">Abgeschlossen</option><option value="sent">Gesendet</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option><option value="overdue">Überfällig</option></select></label>
<label class="field"><span>Ausgestellt</span><input v-model="form.issued_at" type="date" /></label>
<label class="field"><span>Fällig</span><input v-model="form.due_at" type="date" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
</div><div class="sub-panel"><div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div>
<div v-for="(line, index) in form.items" :key="index" class="quote-line">
<label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" required @change="applyItemDefaults(line)" /></label>
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" /></label><label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" /></label><label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label><label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label><button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
</div></div><div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="finalize">Abschließen</button><button v-if="selectedId" type="button" class="secondary" @click="cancelInvoice">Stornieren</button></div><FormStatus :message="status" :kind="kind" /></form></section>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { apiPost } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
const requestForm = reactive({ email: "" });
const resetForm = reactive({ token: "", new_password: "", new_password_confirm: "" });
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
async function requestReset() {
const result = await apiPost<{ queued: boolean; dev_reset_token?: string }>("/api/v1/auth/request-password-reset", requestForm);
status.value = result.ok
? `Reset-E-Mail wurde vorbereitet.${result.data.dev_reset_token ? ` Dev-Token: ${result.data.dev_reset_token}` : ""}`
: result.message;
kind.value = result.ok ? "success" : "error";
}
async function resetPassword() {
const result = await apiPost("/api/v1/auth/reset-password", resetForm);
status.value = result.ok ? "Passwort wurde geändert." : result.message;
kind.value = result.ok ? "success" : "error";
}
</script>
<template>
<PageHeader title="Passwort zurücksetzen" description="Reset-Link anfordern und neues Passwort setzen." />
<section class="panel form-panel">
<form @submit.prevent="requestReset">
<label class="field"><span>E-Mail-Adresse</span><input v-model="requestForm.email" type="email" required /></label>
<div class="form-actions"><button type="submit">Reset anfordern</button></div>
</form>
</section>
<section class="panel form-panel">
<form @submit.prevent="resetPassword">
<label class="field"><span>Reset-Token</span><input v-model="resetForm.token" required /></label>
<label class="field"><span>Neues Passwort</span><input v-model="resetForm.new_password" type="password" required /></label>
<label class="field"><span>Neues Passwort wiederholen</span><input v-model="resetForm.new_password_confirm" type="password" required /></label>
<div class="form-actions"><button type="submit">Passwort setzen</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
</section>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref } from "vue";
import { apiPost } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import type { PriceListImportApplyResponse, PriceListImportPreview } from "../types";
const sourceName = ref("Preisliste.csv");
const delimiter = ref(";");
const content = ref("item_number;name;unit;tax_rate;purchase_price;sales_price\nAR-IMPORT-1;Importartikel;Stk;19;10.00;25.00");
const preview = ref<PriceListImportPreview | null>(null);
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
function payload() {
return { source_name: sourceName.value, delimiter: delimiter.value, content: content.value };
}
async function runPreview() {
const result = await apiPost<PriceListImportPreview>("/api/v1/imports/price-list/preview", payload());
if (result.ok) { preview.value = result.data; status.value = "Vorschau erstellt."; kind.value = "success"; }
else { status.value = result.message; kind.value = "error"; }
}
async function applyImport() {
const result = await apiPost<PriceListImportApplyResponse>("/api/v1/imports/price-list/apply", payload());
if (result.ok) { status.value = `Import ${result.data.import_id}: ${result.data.applied_rows} Zeilen übernommen.`; kind.value = "success"; await runPreview(); }
else { status.value = result.message; kind.value = "error"; }
}
</script>
<template>
<PageHeader title="Preislistenimport" description="CSV-Preislisten prüfen und Artikelpreise historisiert übernehmen." />
<section class="panel">
<div class="form-grid">
<label class="field"><span>Quelle</span><input v-model="sourceName" /></label>
<label class="field"><span>Trennzeichen</span><input v-model="delimiter" /></label>
<label class="field full-width"><span>CSV-Inhalt</span><textarea v-model="content" rows="10" /></label>
</div>
<div class="form-actions"><button type="button" @click="runPreview">Vorschau</button><button type="button" @click="applyImport">Importieren</button></div>
<FormStatus :message="status" :kind="kind" />
</section>
<section v-if="preview" class="panel">
<div class="section-title"><h2>Vorschau</h2><span>{{ preview.valid_rows }} gültig, {{ preview.error_rows }} Fehler</span></div>
<div v-for="row in preview.rows" :key="row.row_number" class="data-row">
<strong>{{ row.row_number }} {{ row.action }}</strong>
<span>{{ row.item_number }}</span>
<span>{{ row.name }}</span>
<small>{{ row.error ?? `${row.purchase_price ?? "-"} / ${row.sales_price ?? "-"}` }}</small>
</div>
</section>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import type { PriceRule } from "../types";
const rules = ref<PriceRule[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive({
code: "",
name: "",
source_type: "import" as PriceRule["source_type"],
source_id: "",
markup_percent: "0.00",
rounding_mode: "none" as PriceRule["rounding_mode"],
is_active: true
});
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
function payload() {
return { ...form, source_id: form.source_id.trim() || null };
}
function createNew() {
selectedId.value = null;
Object.assign(form, {
code: "",
name: "",
source_type: "import",
source_id: "",
markup_percent: "0.00",
rounding_mode: "none",
is_active: true
});
}
function select(rule: PriceRule) {
selectedId.value = rule.id;
Object.assign(form, { ...rule, source_id: rule.source_id ?? "" });
}
async function load() {
const result = await apiGet<PriceRule[]>("/api/v1/price-rules");
if (result.ok) rules.value = result.data;
else { status.value = result.message; kind.value = "error"; }
}
async function save() {
const result = selectedId.value
? await apiPut<PriceRule>(`/api/v1/price-rules/${selectedId.value}`, payload())
: await apiPost<PriceRule>("/api/v1/price-rules", payload());
status.value = result.ok ? "Preisregel gespeichert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function deactivate() {
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/price-rules/${selectedId.value}`);
status.value = result.ok ? "Preisregel deaktiviert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) await load();
}
onMounted(load);
</script>
<template>
<PageHeader title="Preisregeln" description="Aufschläge und Rundung je Preisquelle festlegen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Regeln</h2><button type="button" @click="createNew">Neu</button></div>
<button v-for="rule in rules" :key="rule.id" type="button" class="list-row" :class="{ selected: selectedId === rule.id }" @click="select(rule)">
<strong>{{ rule.code }}</strong>
<span>{{ rule.name }}</span>
<small>{{ rule.source_type }} · {{ rule.markup_percent }} %</small>
</button>
<p v-if="rules.length === 0" class="empty">Keine Preisregeln vorhanden.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save">
<div class="form-grid">
<label class="field"><span>Code</span><input v-model="form.code" required /></label>
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
<label class="field"><span>Quellentyp</span><select v-model="form.source_type"><option value="import">Import</option><option value="api">API</option><option value="supplier">Lieferant</option></select></label>
<label class="field"><span>Quell-ID</span><input v-model="form.source_id" placeholder="optional" /></label>
<label class="field"><span>Aufschlag %</span><input v-model="form.markup_percent" type="number" min="-100" max="1000" step="0.0001" required /></label>
<label class="field"><span>Rundung</span><select v-model="form.rounding_mode"><option value="none">Keine</option><option value="cent">Cent</option><option value="five_cent">5 Cent</option><option value="ten_cent">10 Cent</option><option value="whole">Ganze Beträge</option></select></label>
<label class="check-row"><input v-model="form.is_active" type="checkbox" /><span>Aktiv</span></label>
</div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
</section>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import SearchSelect from "../components/SearchSelect.vue";
import { reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { Customer, Item, Quote, QuoteItem } from "../types";
const emptyItem = (): QuoteItem => ({ item_id: "", description: "", quantity: "1", unit_price: "0", original_unit_price: null, discount_percent: "0", tax_rate: "19" });
const emptyForm = () => ({ quote_number: "", customer_id: "", status: "draft", valid_until: null as string | null, cash_discount_term_id: null as string | null, customer_discount_percent: "0", notes: "", items: [emptyItem()] });
const quotes = ref<Quote[]>([]);
const customers = ref<Customer[]>([]);
const items = ref<Item[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
const selectedQuote = computed(() => quotes.value.find((quote) => quote.id === selectedId.value));
const search = ref("");
const customerName = (customerId: string) => customers.value.find((customer) => customer.id === customerId)?.name ?? customerId;
const filteredQuotes = computed(() => {
const needle = search.value.trim().toLowerCase();
if (!needle) return quotes.value;
return quotes.value.filter((quote) =>
`${quote.quote_number} ${customerName(quote.customer_id)} ${quote.status}`.toLowerCase().includes(needle)
);
});
async function load() {
const [quoteResult, customerResult, itemResult] = await Promise.all([
apiGet<Quote[]>("/api/v1/quotes"),
apiGet<Customer[]>("/api/v1/customers"),
apiGet<Item[]>("/api/v1/items")
]);
if (quoteResult.ok) quotes.value = quoteResult.data; else { status.value = quoteResult.message; kind.value = "error"; }
if (customerResult.ok) customers.value = customerResult.data.filter((customer) => customer.status === "active");
if (itemResult.ok) items.value = itemResult.data.filter((item) => item.status === "active");
}
async function createNew() {
selectedId.value = null;
Object.assign(form, emptyForm());
form.quote_number = (await reserveNextNumber("quotes")) ?? "";
}
function select(quote: Quote) {
selectedId.value = quote.id;
Object.assign(form, { ...quote, items: quote.items.map((item) => ({ ...item })) });
}
function addLine() {
form.items.push(emptyItem());
}
function removeLine(index: number) {
if (form.items.length > 1) form.items.splice(index, 1);
}
function applyItemDefaults(line: QuoteItem) {
const item = items.value.find((record) => record.id === line.item_id);
if (!item) return;
line.description = item.name;
line.unit_price = item.default_sales_price ?? "0";
line.original_unit_price = item.default_sales_price ?? null;
line.tax_rate = item.tax_rate;
}
async function save() {
const result = selectedId.value
? await apiPut<Quote>(`/api/v1/quotes/${selectedId.value}`, form)
: await apiPost<Quote>("/api/v1/quotes", form);
status.value = result.ok ? "Angebot gespeichert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function cancelQuote() {
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/quotes/${selectedId.value}`);
status.value = result.ok ? "Angebot storniert." : result.message;
kind.value = result.ok ? "success" : "error";
if (result.ok) await load();
}
onMounted(load);
watch(() => liveUpdateState.revision, load);
</script>
<template>
<PageHeader title="Angebote" description="Angebote mit festen Artikelpositionen und individuellen Preisen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Angebote</h2><button type="button" @click="createNew">Neu</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Angebotsnummer, Kunde oder Status" /></label>
<button v-for="quote in filteredQuotes" :key="quote.id" type="button" class="list-row" :class="{ selected: selectedId === quote.id }" @click="select(quote)">
<strong>{{ quote.quote_number }}</strong>
<span>{{ customerName(quote.customer_id) }}</span>
<small>{{ quote.status }}</small>
</button>
<p v-if="quotes.length === 0" class="empty">Keine Angebote vorhanden.</p>
<p v-else-if="filteredQuotes.length === 0" class="empty">Keine Treffer.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save">
<div class="form-grid">
<label class="field"><span>Angebotsnummer</span><div class="readonly-value">{{ form.quote_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Kunde</span><SearchSelect v-model="form.customer_id" :options="customers.map((customer) => ({ id: customer.id, number: customer.customer_number, name: customer.name }))" placeholder="Kunden-Nr. oder Name suchen" required /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="draft">Entwurf</option><option value="sent">Gesendet</option><option value="accepted">Angenommen</option><option value="rejected">Abgelehnt</option><option value="expired">Abgelaufen</option><option value="cancelled">Storniert</option></select></label>
<label class="field"><span>Gültig bis</span><input v-model="form.valid_until" type="date" /></label>
<label class="field"><span>Kundenrabatt %</span><input v-model="form.customer_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
<label class="field full-width"><span>Notizen</span><textarea v-model="form.notes" rows="3" /></label>
</div>
<div class="sub-panel">
<div class="section-title"><h2>Positionen</h2><button type="button" @click="addLine">Position</button></div>
<div v-for="(line, index) in form.items" :key="index" class="quote-line">
<label class="field full-width"><span>Artikel</span><SearchSelect v-model="line.item_id" :options="items.map((item) => ({ id: item.id, number: item.item_number, name: item.name }))" placeholder="Artikel-Nr. oder Name suchen" required @change="applyItemDefaults(line)" /></label>
<label class="field"><span>Menge</span><input v-model="line.quantity" type="number" min="0.0001" step="0.01" required /></label>
<label class="field"><span>Preis</span><input v-model="line.unit_price" type="number" min="0" step="0.01" required /></label>
<label class="field"><span>Rabatt %</span><input v-model="line.discount_percent" type="number" min="0" max="100" step="0.01" /></label>
<label class="field"><span>Steuer %</span><input v-model="line.tax_rate" type="number" min="0" step="0.01" /></label>
<label class="field full-width"><span>Beschreibung</span><input v-model="line.description" /></label>
<button type="button" class="secondary" @click="removeLine(index)">Entfernen</button>
</div>
</div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedQuote" type="button" class="secondary" @click="cancelQuote">Stornieren</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
</section>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { apiPost } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
const form = reactive({
organization_name: "",
email: "",
accept_terms: false
});
const pending = ref(false);
const status = ref("");
const statusKind = ref<"info" | "success" | "error">("info");
async function submit() {
pending.value = true;
status.value = "Sende Anfrage...";
statusKind.value = "info";
const result = await apiPost("/api/v1/registration/organization", form);
pending.value = false;
status.value = result.ok
? "Registrierung eingegangen. Nach Freischaltung erhalten Sie eine E-Mail."
: result.message;
statusKind.value = result.ok ? "success" : "error";
}
</script>
<template>
<PageHeader title="Firma registrieren" description="Neue Organization für den SaaS-Betrieb vormerken." />
<section class="panel form-panel">
<form @submit.prevent="submit">
<label class="field">
<span>Firmenname</span>
<input v-model="form.organization_name" name="organization_name" type="text" placeholder="Muster GmbH" required />
</label>
<label class="field">
<span>E-Mail-Adresse</span>
<input v-model="form.email" name="email" type="email" placeholder="admin@example.com" required />
</label>
<label class="check-row">
<input v-model="form.accept_terms" name="accept_terms" type="checkbox" required />
<span>Nutzungsbedingungen akzeptieren</span>
</label>
<div class="form-actions">
<button type="submit" :disabled="pending">Registrierung absenden</button>
</div>
<FormStatus :message="status" :kind="statusKind" />
</form>
</section>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiDelete, apiGet, apiPost, apiPut } from "../api";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { matchesObjectSearch, reserveNextNumber } from "../number-ranges";
import { liveUpdateState } from "../realtime";
import type { CashDiscountTerm, Supplier } from "../types";
const emptyForm = () => ({
supplier_number: "", name: "", status: "active",
details: { street: "", postal_code: "", city: "", country: "Deutschland", email: "", phone: "" },
standard_discount_percent: "0", cash_discount_term_id: null as string | null, payment_days: 14 as number | null
});
const suppliers = ref<Supplier[]>([]);
const cashDiscountTerms = ref<CashDiscountTerm[]>([]);
const selectedId = ref<string | null>(null);
const form = reactive(emptyForm());
const status = ref("");
const kind = ref<"info" | "success" | "error">("info");
const search = ref("");
const filteredSuppliers = computed(() =>
suppliers.value.filter((supplier) => matchesObjectSearch(supplier.supplier_number, supplier.name, search.value))
);
async function load() {
const result = await apiGet<Supplier[]>("/api/v1/suppliers");
if (result.ok) suppliers.value = result.data;
else { status.value = result.message; kind.value = "error"; }
}
async function loadCashDiscountTerms() {
const result = await apiGet<CashDiscountTerm[]>("/api/v1/cash-discount-terms");
if (result.ok) cashDiscountTerms.value = result.data.filter((term) => term.is_active);
}
async function createNew() {
selectedId.value = null;
Object.assign(form, emptyForm());
form.details = { ...emptyForm().details };
form.supplier_number = (await reserveNextNumber("suppliers")) ?? "";
}
function select(item: Supplier) { selectedId.value = item.id; Object.assign(form, { ...item, details: { ...item.details } }); }
async function save() {
const result = selectedId.value
? await apiPut<Supplier>(`/api/v1/suppliers/${selectedId.value}`, form)
: await apiPost<Supplier>("/api/v1/suppliers", form);
status.value = result.ok ? "Lieferant gespeichert." : result.message; kind.value = result.ok ? "success" : "error";
if (result.ok) { selectedId.value = result.data.id; await load(); }
}
async function deactivate() {
if (!selectedId.value) return;
const result = await apiDelete(`/api/v1/suppliers/${selectedId.value}`);
status.value = result.ok ? "Lieferant deaktiviert." : result.message; kind.value = result.ok ? "success" : "error";
if (result.ok) await load();
}
onMounted(async () => {
await Promise.all([load(), loadCashDiscountTerms()]);
});
watch(() => liveUpdateState.revision, () => Promise.all([load(), loadCashDiscountTerms()]));
</script>
<template>
<PageHeader title="Lieferanten" description="Lieferantenstamm und Zahlungskonditionen." />
<div class="workspace-split">
<section class="panel list-panel">
<div class="section-title"><h2>Lieferanten</h2><button type="button" @click="createNew">Neu</button></div>
<label class="field list-search"><span>Suche</span><input v-model="search" type="search" placeholder="Lieferantennummer oder Name" /></label>
<button v-for="item in filteredSuppliers" :key="item.id" type="button" class="list-row" :class="{ selected: selectedId === item.id }" @click="select(item)">
<strong>{{ item.supplier_number }}</strong><span>{{ item.name }}</span><small>{{ item.status }}</small>
</button>
<p v-if="suppliers.length === 0" class="empty">Keine Lieferanten vorhanden.</p>
<p v-else-if="filteredSuppliers.length === 0" class="empty">Keine Treffer.</p>
</section>
<section class="panel detail-panel">
<form @submit.prevent="save">
<div class="form-grid">
<label class="field"><span>Lieferantennummer</span><div class="readonly-value">{{ form.supplier_number || "wird automatisch vergeben" }}</div></label>
<label class="field"><span>Name</span><input v-model="form.name" required /></label>
<label class="field"><span>Status</span><select v-model="form.status"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="blocked">Gesperrt</option></select></label>
<label class="field"><span>Rabatt %</span><input v-model="form.standard_discount_percent" type="number" min="0" max="100" step="0.01" /></label>
<label class="field">
<span>Skonto-Regel</span>
<select v-model="form.cash_discount_term_id">
<option :value="null">Keine</option>
<option v-for="term in cashDiscountTerms" :key="term.id" :value="term.id">{{ term.code }} - {{ term.name }}</option>
</select>
</label>
<label class="field"><span>Zahlungsziel Tage</span><input v-model="form.payment_days" type="number" min="0" /></label>
<label class="field"><span>Straße</span><input v-model="form.details.street" /></label>
<label class="field"><span>PLZ</span><input v-model="form.details.postal_code" /></label>
<label class="field"><span>Ort</span><input v-model="form.details.city" /></label>
<label class="field"><span>E-Mail</span><input v-model="form.details.email" type="email" /></label>
<label class="field"><span>Telefon</span><input v-model="form.details.phone" /></label>
</div>
<div class="form-actions"><button type="submit">Speichern</button><button v-if="selectedId" type="button" class="secondary" @click="deactivate">Deaktivieren</button></div>
<FormStatus :message="status" :kind="kind" />
</form>
</section>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { apiGet, apiPatch, apiPost } from "../api";
import { authState } from "../auth";
import FormStatus from "../components/FormStatus.vue";
import PageHeader from "../components/PageHeader.vue";
import { liveUpdateState } from "../realtime";
import type { OrganizationUser } from "../types";
const availableRoles = [
{ code: "owner", label: "Besitzer" },
{ code: "admin", label: "Admin" },
{ code: "sales", label: "Vertrieb" },
{ code: "accounting", label: "Buchhaltung" },
{ code: "viewer", label: "Lesen" }
];
const form = reactive({
email: "",
roles: ["viewer"]
});
const users = ref<OrganizationUser[]>([]);
const listMessage = ref("Noch keine Benutzer geladen.");
const status = ref("");
const statusKind = ref<"info" | "success" | "error">("info");
const roleDrafts = ref<Record<string, string[]>>({});
const savingUserId = ref<string | null>(null);
const currentUser = computed(() => users.value.find((user) => user.user_id === authState.session?.userId));
const canManageRoles = computed(() => currentUser.value?.roles.includes("owner") === true);
async function invite() {
const result = await apiPost<{ dev_invitation_token?: string }>("/api/v1/organizations/current/invitations", form);
status.value = result.ok
? `Einladung angelegt.${result.data.dev_invitation_token ? ` Dev-Token: ${result.data.dev_invitation_token}` : ""}`
: result.message;
statusKind.value = result.ok ? "success" : "error";
if (result.ok) {
form.email = "";
form.roles = ["viewer"];
await loadUsers();
}
}
async function loadUsers() {
const result = await apiGet<OrganizationUser[]>("/api/v1/organizations/current/users");
if (!result.ok) {
users.value = [];
listMessage.value = result.message;
return;
}
users.value = result.data;
roleDrafts.value = Object.fromEntries(result.data.map((user) => [user.user_id, [...user.roles]]));
listMessage.value = "Keine Benutzer vorhanden.";
}
async function saveRoles(user: OrganizationUser) {
const roles = roleDrafts.value[user.user_id] ?? [];
savingUserId.value = user.user_id;
const result = await apiPatch(`/api/v1/organizations/current/users/${encodeURIComponent(user.user_id)}/roles`, { roles });
savingUserId.value = null;
status.value = result.ok ? "Benutzerrechte gespeichert." : result.message;
statusKind.value = result.ok ? "success" : "error";
if (result.ok) await loadUsers();
}
function roleLabel(code: string) {
return availableRoles.find((role) => role.code === code)?.label ?? code;
}
onMounted(loadUsers);
watch(
() => liveUpdateState.revision,
() => {
if (users.value.length > 0) loadUsers();
}
);
</script>
<template>
<PageHeader title="Benutzerrechte" description="Benutzer einladen und Rollen verwalten." />
<section class="panel form-panel">
<form @submit.prevent="invite">
<label class="field">
<span>E-Mail-Adresse</span>
<input v-model="form.email" type="email" placeholder="kollege@example.com" required />
</label>
<label class="field">
<span>Rollen</span>
<select v-model="form.roles" multiple>
<option v-for="role in availableRoles" :key="role.code" :value="role.code">{{ role.label }}</option>
</select>
</label>
<div class="form-actions">
<button type="submit" :disabled="!canManageRoles">Einladung senden</button>
<button type="button" class="secondary" @click="loadUsers">Benutzer laden</button>
</div>
<p v-if="!canManageRoles" class="muted">Nur der Besitzer der Firma darf Rechte vergeben oder ändern.</p>
<FormStatus :message="status" :kind="statusKind" />
</form>
</section>
<section class="panel">
<div class="section-title">
<h2>Benutzerliste</h2>
</div>
<p v-if="users.length === 0" class="empty">{{ listMessage }}</p>
<div v-else class="data-table users">
<div class="table-head">E-Mail</div>
<div class="table-head">Status</div>
<div class="table-head">Rollen</div>
<div class="table-head">Aktion</div>
<template v-for="user in users" :key="user.user_id">
<div>{{ user.email }}</div>
<span class="status-pill">{{ user.status }}</span>
<div v-if="canManageRoles" class="role-checks">
<label v-for="role in availableRoles" :key="role.code" class="check-row compact">
<input v-model="roleDrafts[user.user_id]" type="checkbox" :value="role.code" />
<span>{{ role.label }}</span>
</label>
</div>
<div v-else>{{ user.roles.map(roleLabel).join(", ") }}</div>
<div>
<button
type="button"
class="secondary"
:disabled="!canManageRoles || savingUserId === user.user_id"
@click="saveRoles(user)"
>
Speichern
</button>
</div>
</template>
</div>
</section>
</template>

2
web-frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const backendOrigin = env.VITE_BACKEND_ORIGIN ?? "http://127.0.0.1:8080";
return {
plugins: [vue()],
server: {
port: 5175,
proxy: {
"/api": {
target: backendOrigin,
changeOrigin: true
},
"/ws": {
target: backendOrigin,
ws: true,
changeOrigin: true
}
}
}
};
});