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:
13
web-frontend/index.html
Normal file
13
web-frontend/index.html
Normal 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
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
21
web-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
web-frontend/public/companytool-logo.png
Normal file
BIN
web-frontend/public/companytool-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
399
web-frontend/src/App.vue
Normal file
399
web-frontend/src/App.vue
Normal 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
51
web-frontend/src/api.ts
Normal 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
44
web-frontend/src/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
web-frontend/src/components/FormStatus.vue
Normal file
10
web-frontend/src/components/FormStatus.vue
Normal 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>
|
||||
15
web-frontend/src/components/PageHeader.vue
Normal file
15
web-frontend/src/components/PageHeader.vue
Normal 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>
|
||||
75
web-frontend/src/components/SearchSelect.vue
Normal file
75
web-frontend/src/components/SearchSelect.vue
Normal 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
6
web-frontend/src/main.ts
Normal 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");
|
||||
17
web-frontend/src/number-ranges.ts
Normal file
17
web-frontend/src/number-ranges.ts
Normal 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);
|
||||
}
|
||||
179
web-frontend/src/realtime.ts
Normal file
179
web-frontend/src/realtime.ts
Normal 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
105
web-frontend/src/router.ts
Normal 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
788
web-frontend/src/styles.css
Normal 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
348
web-frontend/src/types.ts
Normal 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;
|
||||
};
|
||||
32
web-frontend/src/user-settings.ts
Normal file
32
web-frontend/src/user-settings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
web-frontend/src/utils.ts
Normal file
4
web-frontend/src/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function formatDate(value?: string | null) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleString("de-DE");
|
||||
}
|
||||
32
web-frontend/src/views/AcceptInvitationPage.vue
Normal file
32
web-frontend/src/views/AcceptInvitationPage.vue
Normal 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>
|
||||
39
web-frontend/src/views/ActivitiesPage.vue
Normal file
39
web-frontend/src/views/ActivitiesPage.vue
Normal 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>
|
||||
64
web-frontend/src/views/AdminRegistrationDetailPage.vue
Normal file
64
web-frontend/src/views/AdminRegistrationDetailPage.vue
Normal 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>
|
||||
56
web-frontend/src/views/AdminRegistrationsPage.vue
Normal file
56
web-frontend/src/views/AdminRegistrationsPage.vue
Normal 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>
|
||||
31
web-frontend/src/views/ApiConnectorsPage.vue
Normal file
31
web-frontend/src/views/ApiConnectorsPage.vue
Normal 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>
|
||||
107
web-frontend/src/views/CashDiscountTermsPage.vue
Normal file
107
web-frontend/src/views/CashDiscountTermsPage.vue
Normal 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>
|
||||
69
web-frontend/src/views/ChangeInitialPasswordPage.vue
Normal file
69
web-frontend/src/views/ChangeInitialPasswordPage.vue
Normal 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>
|
||||
112
web-frontend/src/views/CommunicationsPage.vue
Normal file
112
web-frontend/src/views/CommunicationsPage.vue
Normal 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>
|
||||
171
web-frontend/src/views/CustomersPage.vue
Normal file
171
web-frontend/src/views/CustomersPage.vue
Normal 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>
|
||||
32
web-frontend/src/views/DashboardPage.vue
Normal file
32
web-frontend/src/views/DashboardPage.vue
Normal 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>
|
||||
147
web-frontend/src/views/DocumentsPage.vue
Normal file
147
web-frontend/src/views/DocumentsPage.vue
Normal 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>
|
||||
38
web-frontend/src/views/IncomingInvoicesPage.vue
Normal file
38
web-frontend/src/views/IncomingInvoicesPage.vue
Normal 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>
|
||||
67
web-frontend/src/views/ItemsPage.vue
Normal file
67
web-frontend/src/views/ItemsPage.vue
Normal 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>
|
||||
122
web-frontend/src/views/LoginPage.vue
Normal file
122
web-frontend/src/views/LoginPage.vue
Normal 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>
|
||||
91
web-frontend/src/views/NumberRangesPage.vue
Normal file
91
web-frontend/src/views/NumberRangesPage.vue
Normal 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>
|
||||
115
web-frontend/src/views/OrganizationSetupPage.vue
Normal file
115
web-frontend/src/views/OrganizationSetupPage.vue
Normal 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>
|
||||
82
web-frontend/src/views/OutgoingInvoicesPage.vue
Normal file
82
web-frontend/src/views/OutgoingInvoicesPage.vue
Normal 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>
|
||||
44
web-frontend/src/views/PasswordResetPage.vue
Normal file
44
web-frontend/src/views/PasswordResetPage.vue
Normal 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>
|
||||
51
web-frontend/src/views/PriceImportsPage.vue
Normal file
51
web-frontend/src/views/PriceImportsPage.vue
Normal 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>
|
||||
98
web-frontend/src/views/PriceRulesPage.vue
Normal file
98
web-frontend/src/views/PriceRulesPage.vue
Normal 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>
|
||||
133
web-frontend/src/views/QuotesPage.vue
Normal file
133
web-frontend/src/views/QuotesPage.vue
Normal 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>
|
||||
52
web-frontend/src/views/RegisterPage.vue
Normal file
52
web-frontend/src/views/RegisterPage.vue
Normal 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>
|
||||
98
web-frontend/src/views/SuppliersPage.vue
Normal file
98
web-frontend/src/views/SuppliersPage.vue
Normal 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>
|
||||
134
web-frontend/src/views/UsersPage.vue
Normal file
134
web-frontend/src/views/UsersPage.vue
Normal 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
2
web-frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
17
web-frontend/tsconfig.json
Normal file
17
web-frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
||||
25
web-frontend/vite.config.ts
Normal file
25
web-frontend/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user