init
This commit is contained in:
310
frontend/src/app.css
Normal file
310
frontend/src/app.css
Normal file
@@ -0,0 +1,310 @@
|
||||
:root {
|
||||
--color-bg: #1a1a2e;
|
||||
--color-bg-secondary: #16213e;
|
||||
--color-bg-card: #0f3460;
|
||||
--color-primary: #e94560;
|
||||
--color-primary-hover: #ff6b81;
|
||||
--color-text: #eaeaea;
|
||||
--color-text-muted: #a0a0b0;
|
||||
--color-success: #2ed573;
|
||||
--color-warning: #ffa502;
|
||||
--color-border: #2a2a4a;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Touch-optimierte Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, background-color 0.2s;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--color-success);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Kacheln für Touch-Auswahl */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.2s;
|
||||
touch-action: manipulation;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 20px rgba(233, 69, 96, 0.3);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card .subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.page-header .back-btn {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Toast-Benachrichtigung */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-success);
|
||||
color: #1a1a2e;
|
||||
padding: 16px 32px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
animation: toast-in 0.3s ease-out, toast-out 0.3s ease-in 1.7s forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Admin-Tabellen */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Admin-Layout */
|
||||
.admin-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Formulare */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
min-width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: rgba(46, 213, 115, 0.2);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: rgba(255, 165, 2, 0.2);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* Preis-Formatierung */
|
||||
.price {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Monatswähler */
|
||||
.month-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.month-selector input[type="month"] {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
15
frontend/src/app.html
Normal file
15
frontend/src/app.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
24
frontend/src/hooks.server.ts
Normal file
24
frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://localhost:8080';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (event.url.pathname.startsWith('/api')) {
|
||||
const targetUrl = `${API_URL}${event.url.pathname}${event.url.search}`;
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: event.request.method,
|
||||
headers: event.request.headers,
|
||||
body: ['GET', 'HEAD'].includes(event.request.method) ? undefined : event.request.body,
|
||||
// @ts-ignore
|
||||
duplex: 'half'
|
||||
});
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers
|
||||
});
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
153
frontend/src/lib/api/client.ts
Normal file
153
frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status}`);
|
||||
}
|
||||
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||
return undefined as T;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: number;
|
||||
companyId: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
priceCents: number;
|
||||
iconPlaceholder: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface MonthlyTally {
|
||||
productName: string;
|
||||
priceCents: number;
|
||||
count: number;
|
||||
totalCents: number;
|
||||
}
|
||||
|
||||
export interface EmployeeReportLine {
|
||||
employeeId: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
totalCount: number;
|
||||
totalCents: number;
|
||||
products: MonthlyTally[];
|
||||
}
|
||||
|
||||
export interface MonthlyReport {
|
||||
month: string;
|
||||
companyId: number | null;
|
||||
companyName: string | null;
|
||||
employees: EmployeeReportLine[];
|
||||
totalCents: number;
|
||||
}
|
||||
|
||||
export interface AccessLink {
|
||||
id: number;
|
||||
token: string;
|
||||
role: string;
|
||||
companyId: number | null;
|
||||
description: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderReport {
|
||||
month: string;
|
||||
companies: MonthlyReport[];
|
||||
grandTotalCents: number;
|
||||
}
|
||||
|
||||
// --- Public (iPad) ---
|
||||
export const api = {
|
||||
getCompanies: () => request<Company[]>('/companies'),
|
||||
getEmployees: (companyId: number) => request<Employee[]>(`/companies/${companyId}/employees`),
|
||||
getProducts: () => request<Product[]>('/products'),
|
||||
createTally: (employeeId: number, productId: number) =>
|
||||
request<void>('/tally', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ employeeId, productId })
|
||||
}),
|
||||
getMonthlyTally: (employeeId: number, month?: string) =>
|
||||
request<MonthlyTally[]>(`/tally/monthly/${employeeId}${month ? `?month=${month}` : ''}`),
|
||||
|
||||
// --- Company Admin ---
|
||||
companyAdmin: {
|
||||
getEmployees: (token: string) =>
|
||||
request<Employee[]>(`/admin/company/employees?token=${token}`),
|
||||
createEmployee: (token: string, firstName: string, lastName: string) =>
|
||||
request<Employee>(`/admin/company/employees?token=${token}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ firstName, lastName })
|
||||
}),
|
||||
updateEmployee: (token: string, id: number, firstName: string, lastName: string) =>
|
||||
request<Employee>(`/admin/company/employees/${id}?token=${token}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ firstName, lastName })
|
||||
}),
|
||||
toggleEmployee: (token: string, id: number) =>
|
||||
request<Employee>(`/admin/company/employees/${id}/toggle?token=${token}`, { method: 'PUT' }),
|
||||
getReport: (token: string, month?: string) =>
|
||||
request<MonthlyReport>(`/admin/company/report?token=${token}${month ? `&month=${month}` : ''}`),
|
||||
getEmployeeReport: (token: string, employeeId: number, month?: string) =>
|
||||
request<EmployeeReportLine>(`/admin/company/report/employee/${employeeId}?token=${token}${month ? `&month=${month}` : ''}`)
|
||||
},
|
||||
|
||||
// --- Provider Admin ---
|
||||
providerAdmin: {
|
||||
getCompanies: (token: string) =>
|
||||
request<Company[]>(`/admin/provider/companies?token=${token}`),
|
||||
createCompany: (token: string, name: string) =>
|
||||
request<Company>(`/admin/provider/companies?token=${token}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name })
|
||||
}),
|
||||
updateCompany: (token: string, id: number, name: string) =>
|
||||
request<Company>(`/admin/provider/companies/${id}?token=${token}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name })
|
||||
}),
|
||||
toggleCompany: (token: string, id: number) =>
|
||||
request<Company>(`/admin/provider/companies/${id}/toggle?token=${token}`, { method: 'PUT' }),
|
||||
getProducts: (token: string) =>
|
||||
request<Product[]>(`/admin/provider/products?token=${token}`),
|
||||
createProduct: (token: string, name: string, priceCents: number, iconPlaceholder?: string) =>
|
||||
request<Product>(`/admin/provider/products?token=${token}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, priceCents, iconPlaceholder })
|
||||
}),
|
||||
updateProduct: (token: string, id: number, name: string, priceCents: number, iconPlaceholder?: string) =>
|
||||
request<Product>(`/admin/provider/products/${id}?token=${token}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, priceCents, iconPlaceholder })
|
||||
}),
|
||||
toggleProduct: (token: string, id: number) =>
|
||||
request<Product>(`/admin/provider/products/${id}/toggle?token=${token}`, { method: 'PUT' }),
|
||||
getReport: (token: string, month?: string) =>
|
||||
request<ProviderReport>(`/admin/provider/report?token=${token}${month ? `&month=${month}` : ''}`),
|
||||
getAccessLinks: (token: string) =>
|
||||
request<AccessLink[]>(`/admin/provider/access-links?token=${token}`),
|
||||
createAccessLink: (token: string, role: string, companyId?: number, description?: string) =>
|
||||
request<AccessLink>(`/admin/provider/access-links?token=${token}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ role, companyId, description })
|
||||
})
|
||||
}
|
||||
};
|
||||
5
frontend/src/routes/+layout.svelte
Normal file
5
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
36
frontend/src/routes/+page.svelte
Normal file
36
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Company } from '$lib/api/client';
|
||||
|
||||
let companies: Company[] = [];
|
||||
let loading = true;
|
||||
|
||||
onMount(async () => {
|
||||
companies = await api.getCompanies();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Strichliste</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Strichliste</h1>
|
||||
<p style="color: var(--color-text-muted); margin-top: 4px;">Firma auswählen</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div style="text-align: center; padding: 48px;">
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid" style="padding: 24px;">
|
||||
{#each companies as company}
|
||||
<a href="/company/{company.id}" class="card">
|
||||
<div style="font-size: 2.5rem;">🏢</div>
|
||||
<h3>{company.name}</h3>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
228
frontend/src/routes/admin/company/+page.svelte
Normal file
228
frontend/src/routes/admin/company/+page.svelte
Normal file
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api, type Employee, type MonthlyReport, type EmployeeReportLine } from '$lib/api/client';
|
||||
|
||||
let token = '';
|
||||
let employees: Employee[] = [];
|
||||
let report: MonthlyReport | null = null;
|
||||
let selectedEmployee: EmployeeReportLine | null = null;
|
||||
let loading = true;
|
||||
let selectedMonth = new Date().toISOString().slice(0, 7);
|
||||
|
||||
// Modal state
|
||||
let showModal = false;
|
||||
let editId: number | null = null;
|
||||
let formFirstName = '';
|
||||
let formLastName = '';
|
||||
|
||||
$: token = $page.url.searchParams.get('token') ?? '';
|
||||
|
||||
onMount(async () => {
|
||||
if (!token) return;
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
[employees, report] = await Promise.all([
|
||||
api.companyAdmin.getEmployees(token),
|
||||
api.companyAdmin.getReport(token, selectedMonth)
|
||||
]);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function changeMonth() {
|
||||
report = await api.companyAdmin.getReport(token, selectedMonth);
|
||||
selectedEmployee = null;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editId = null;
|
||||
formFirstName = '';
|
||||
formLastName = '';
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEdit(emp: Employee) {
|
||||
editId = emp.id;
|
||||
formFirstName = emp.firstName;
|
||||
formLastName = emp.lastName;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function saveEmployee() {
|
||||
if (editId) {
|
||||
await api.companyAdmin.updateEmployee(token, editId, formFirstName, formLastName);
|
||||
} else {
|
||||
await api.companyAdmin.createEmployee(token, formFirstName, formLastName);
|
||||
}
|
||||
showModal = false;
|
||||
await loadData();
|
||||
}
|
||||
|
||||
async function toggleEmployee(id: number) {
|
||||
await api.companyAdmin.toggleEmployee(token, id);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
async function showEmployeeDetail(employeeId: number) {
|
||||
selectedEmployee = await api.companyAdmin.getEmployeeReport(token, employeeId, selectedMonth);
|
||||
}
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return (cents / 100).toFixed(2).replace('.', ',') + ' €';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Firmen-Admin - Strichliste</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>Firmen-Administration</h1>
|
||||
</div>
|
||||
|
||||
{#if !token}
|
||||
<p>Kein Zugangstoken angegeben.</p>
|
||||
{:else if loading}
|
||||
<p>Laden...</p>
|
||||
{:else}
|
||||
<!-- Mitarbeiterverwaltung -->
|
||||
<section style="margin-bottom: 40px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h2>Mitarbeiter</h2>
|
||||
<button class="btn btn-primary" on:click={openCreate}>+ Mitarbeiter anlegen</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each employees as emp}
|
||||
<tr>
|
||||
<td>{emp.firstName} {emp.lastName}</td>
|
||||
<td>
|
||||
<span class="badge" class:badge-active={emp.active} class:badge-inactive={!emp.active}>
|
||||
{emp.active ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => openEdit(emp)}>Bearbeiten</button>
|
||||
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; margin-left: 8px;" on:click={() => toggleEmployee(emp.id)}>
|
||||
{emp.active ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Monatsauswertung -->
|
||||
<section>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h2>Monatsauswertung</h2>
|
||||
<div class="month-selector">
|
||||
<input type="month" bind:value={selectedMonth} on:change={changeMonth} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if report}
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter</th>
|
||||
<th style="text-align: right;">Anzahl</th>
|
||||
<th style="text-align: right;">Summe</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each report.employees as line}
|
||||
<tr>
|
||||
<td>{line.firstName} {line.lastName}</td>
|
||||
<td style="text-align: right;">{line.totalCount}×</td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(line.totalCents)}</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => showEmployeeDetail(line.employeeId)}>Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if report.employees.length > 0}
|
||||
<tr style="font-weight: 700;">
|
||||
<td>Gesamt</td>
|
||||
<td></td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(report.totalCents)}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="4" style="text-align: center; color: var(--color-text-muted);">Keine Einträge in diesem Monat</td></tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
{#if selectedEmployee}
|
||||
<div style="margin-top: 24px; background: var(--color-bg-secondary); padding: 20px; border-radius: var(--radius);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3>Detail: {selectedEmployee.firstName} {selectedEmployee.lastName}</h3>
|
||||
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => selectedEmployee = null}>Schließen</button>
|
||||
</div>
|
||||
<table class="admin-table" style="margin-top: 12px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produkt</th>
|
||||
<th style="text-align: right;">Einzelpreis</th>
|
||||
<th style="text-align: right;">Anzahl</th>
|
||||
<th style="text-align: right;">Summe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each selectedEmployee.products as prod}
|
||||
<tr>
|
||||
<td>{prod.productName}</td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(prod.priceCents)}</td>
|
||||
<td style="text-align: right;">{prod.count}×</td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(prod.totalCents)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr style="font-weight: 700;">
|
||||
<td>Gesamt</td>
|
||||
<td></td>
|
||||
<td style="text-align: right;">{selectedEmployee.totalCount}×</td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(selectedEmployee.totalCents)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showModal}
|
||||
<div class="modal-overlay" on:click|self={() => showModal = false}>
|
||||
<div class="modal">
|
||||
<h2>{editId ? 'Mitarbeiter bearbeiten' : 'Neuer Mitarbeiter'}</h2>
|
||||
<div class="form-group">
|
||||
<label for="firstName">Vorname</label>
|
||||
<input id="firstName" type="text" bind:value={formFirstName} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lastName">Nachname</label>
|
||||
<input id="lastName" type="text" bind:value={formLastName} />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" on:click={() => showModal = false}>Abbrechen</button>
|
||||
<button class="btn btn-primary" on:click={saveEmployee}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
402
frontend/src/routes/admin/provider/+page.svelte
Normal file
402
frontend/src/routes/admin/provider/+page.svelte
Normal file
@@ -0,0 +1,402 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api, type Company, type Product, type ProviderReport, type AccessLink } from '$lib/api/client';
|
||||
|
||||
let token = '';
|
||||
let activeTab: 'companies' | 'products' | 'report' | 'links' = 'companies';
|
||||
|
||||
// Data
|
||||
let companies: Company[] = [];
|
||||
let products: Product[] = [];
|
||||
let report: ProviderReport | null = null;
|
||||
let accessLinks: AccessLink[] = [];
|
||||
let loading = true;
|
||||
let selectedMonth = new Date().toISOString().slice(0, 7);
|
||||
|
||||
// Company Modal
|
||||
let showCompanyModal = false;
|
||||
let editCompanyId: number | null = null;
|
||||
let formCompanyName = '';
|
||||
|
||||
// Product Modal
|
||||
let showProductModal = false;
|
||||
let editProductId: number | null = null;
|
||||
let formProductName = '';
|
||||
let formProductPrice = 0;
|
||||
let formProductIcon = 'coffee';
|
||||
|
||||
// Link Modal
|
||||
let showLinkModal = false;
|
||||
let formLinkRole = 'COMPANY_ADMIN';
|
||||
let formLinkCompanyId: number | null = null;
|
||||
let formLinkDescription = '';
|
||||
|
||||
$: token = $page.url.searchParams.get('token') ?? '';
|
||||
|
||||
onMount(async () => {
|
||||
if (!token) return;
|
||||
await loadAll();
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
loading = true;
|
||||
[companies, products, accessLinks] = await Promise.all([
|
||||
api.providerAdmin.getCompanies(token),
|
||||
api.providerAdmin.getProducts(token),
|
||||
api.providerAdmin.getAccessLinks(token)
|
||||
]);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function loadReport() {
|
||||
report = await api.providerAdmin.getReport(token, selectedMonth);
|
||||
}
|
||||
|
||||
async function switchTab(tab: typeof activeTab) {
|
||||
activeTab = tab;
|
||||
if (tab === 'report' && !report) {
|
||||
await loadReport();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Company CRUD ---
|
||||
function openCreateCompany() {
|
||||
editCompanyId = null;
|
||||
formCompanyName = '';
|
||||
showCompanyModal = true;
|
||||
}
|
||||
|
||||
function openEditCompany(c: Company) {
|
||||
editCompanyId = c.id;
|
||||
formCompanyName = c.name;
|
||||
showCompanyModal = true;
|
||||
}
|
||||
|
||||
async function saveCompany() {
|
||||
if (editCompanyId) {
|
||||
await api.providerAdmin.updateCompany(token, editCompanyId, formCompanyName);
|
||||
} else {
|
||||
await api.providerAdmin.createCompany(token, formCompanyName);
|
||||
}
|
||||
showCompanyModal = false;
|
||||
companies = await api.providerAdmin.getCompanies(token);
|
||||
}
|
||||
|
||||
async function toggleCompany(id: number) {
|
||||
await api.providerAdmin.toggleCompany(token, id);
|
||||
companies = await api.providerAdmin.getCompanies(token);
|
||||
}
|
||||
|
||||
// --- Product CRUD ---
|
||||
function openCreateProduct() {
|
||||
editProductId = null;
|
||||
formProductName = '';
|
||||
formProductPrice = 0;
|
||||
formProductIcon = 'coffee';
|
||||
showProductModal = true;
|
||||
}
|
||||
|
||||
function openEditProduct(p: Product) {
|
||||
editProductId = p.id;
|
||||
formProductName = p.name;
|
||||
formProductPrice = p.priceCents;
|
||||
formProductIcon = p.iconPlaceholder;
|
||||
showProductModal = true;
|
||||
}
|
||||
|
||||
async function saveProduct() {
|
||||
if (editProductId) {
|
||||
await api.providerAdmin.updateProduct(token, editProductId, formProductName, formProductPrice, formProductIcon);
|
||||
} else {
|
||||
await api.providerAdmin.createProduct(token, formProductName, formProductPrice, formProductIcon);
|
||||
}
|
||||
showProductModal = false;
|
||||
products = await api.providerAdmin.getProducts(token);
|
||||
}
|
||||
|
||||
async function toggleProduct(id: number) {
|
||||
await api.providerAdmin.toggleProduct(token, id);
|
||||
products = await api.providerAdmin.getProducts(token);
|
||||
}
|
||||
|
||||
// --- Access Links ---
|
||||
function openCreateLink() {
|
||||
formLinkRole = 'COMPANY_ADMIN';
|
||||
formLinkCompanyId = companies.length > 0 ? companies[0].id : null;
|
||||
formLinkDescription = '';
|
||||
showLinkModal = true;
|
||||
}
|
||||
|
||||
async function saveLink() {
|
||||
await api.providerAdmin.createAccessLink(
|
||||
token,
|
||||
formLinkRole,
|
||||
formLinkRole === 'COMPANY_ADMIN' ? formLinkCompanyId ?? undefined : undefined,
|
||||
formLinkDescription
|
||||
);
|
||||
showLinkModal = false;
|
||||
accessLinks = await api.providerAdmin.getAccessLinks(token);
|
||||
}
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return (cents / 100).toFixed(2).replace('.', ',') + ' €';
|
||||
}
|
||||
|
||||
function buildLink(link: AccessLink): string {
|
||||
const base = window.location.origin;
|
||||
if (link.role === 'COMPANY_ADMIN') {
|
||||
return `${base}/admin/company?token=${link.token}`;
|
||||
}
|
||||
return `${base}/admin/provider?token=${link.token}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Anbieter-Admin - Strichliste</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>Anbieter-Administration</h1>
|
||||
</div>
|
||||
|
||||
{#if !token}
|
||||
<p>Kein Zugangstoken angegeben.</p>
|
||||
{:else if loading}
|
||||
<p>Laden...</p>
|
||||
{:else}
|
||||
<!-- Tab Navigation -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 24px; border-bottom: 1px solid var(--color-border); padding-bottom: 12px;">
|
||||
{#each [
|
||||
{ key: 'companies', label: 'Firmen' },
|
||||
{ key: 'products', label: 'Produkte' },
|
||||
{ key: 'report', label: 'Auswertung' },
|
||||
{ key: 'links', label: 'Zugangslinks' }
|
||||
] as tab}
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
style="padding: 8px 20px; {activeTab === tab.key ? 'background: var(--color-primary); color: white; border-color: var(--color-primary);' : ''}"
|
||||
on:click={() => switchTab(tab.key as typeof activeTab)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Firmen -->
|
||||
{#if activeTab === 'companies'}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h2>Firmen</h2>
|
||||
<button class="btn btn-primary" on:click={openCreateCompany}>+ Firma anlegen</button>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Status</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each companies as c}
|
||||
<tr>
|
||||
<td>{c.name}</td>
|
||||
<td>
|
||||
<span class="badge" class:badge-active={c.active} class:badge-inactive={!c.active}>
|
||||
{c.active ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => openEditCompany(c)}>Bearbeiten</button>
|
||||
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; margin-left: 8px;" on:click={() => toggleCompany(c.id)}>
|
||||
{c.active ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<!-- Produkte -->
|
||||
{#if activeTab === 'products'}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h2>Produkte</h2>
|
||||
<button class="btn btn-primary" on:click={openCreateProduct}>+ Produkt anlegen</button>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th style="text-align: right;">Preis</th><th>Icon</th><th>Status</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each products as p}
|
||||
<tr>
|
||||
<td>{p.name}</td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(p.priceCents)}</td>
|
||||
<td>{p.iconPlaceholder}</td>
|
||||
<td>
|
||||
<span class="badge" class:badge-active={p.active} class:badge-inactive={!p.active}>
|
||||
{p.active ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" on:click={() => openEditProduct(p)}>Bearbeiten</button>
|
||||
<button class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; margin-left: 8px;" on:click={() => toggleProduct(p.id)}>
|
||||
{p.active ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<!-- Auswertung -->
|
||||
{#if activeTab === 'report'}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h2>Gesamtauswertung</h2>
|
||||
<div class="month-selector">
|
||||
<input type="month" bind:value={selectedMonth} on:change={loadReport} />
|
||||
</div>
|
||||
</div>
|
||||
{#if report}
|
||||
{#each report.companies as companyReport}
|
||||
<div style="margin-bottom: 24px; background: var(--color-bg-secondary); padding: 20px; border-radius: var(--radius);">
|
||||
<h3 style="margin-bottom: 12px;">{companyReport.companyName ?? 'Firma'}</h3>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter</th>
|
||||
<th style="text-align: right;">Anzahl</th>
|
||||
<th style="text-align: right;">Summe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each companyReport.employees as line}
|
||||
<tr>
|
||||
<td>{line.firstName} {line.lastName}</td>
|
||||
<td style="text-align: right;">{line.totalCount}×</td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(line.totalCents)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr style="font-weight: 700;">
|
||||
<td>Firma-Gesamt</td>
|
||||
<td></td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(companyReport.totalCents)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
<div style="text-align: right; font-size: 1.2rem; font-weight: 700; padding: 16px;">
|
||||
Gesamtsumme: <span class="price">{formatPrice(report.grandTotalCents)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Zugangslinks -->
|
||||
{#if activeTab === 'links'}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h2>Zugangslinks</h2>
|
||||
<button class="btn btn-primary" on:click={openCreateLink}>+ Link erstellen</button>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Beschreibung</th><th>Rolle</th><th>Link</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each accessLinks as link}
|
||||
<tr>
|
||||
<td>{link.description ?? '-'}</td>
|
||||
<td>
|
||||
<span class="badge badge-active">
|
||||
{link.role === 'COMPANY_ADMIN' ? 'Firmen-Admin' : 'Anbieter-Admin'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<code style="font-size: 0.8rem; word-break: break-all;">{buildLink(link)}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Company Modal -->
|
||||
{#if showCompanyModal}
|
||||
<div class="modal-overlay" on:click|self={() => showCompanyModal = false}>
|
||||
<div class="modal">
|
||||
<h2>{editCompanyId ? 'Firma bearbeiten' : 'Neue Firma'}</h2>
|
||||
<div class="form-group">
|
||||
<label for="companyName">Firmenname</label>
|
||||
<input id="companyName" type="text" bind:value={formCompanyName} />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" on:click={() => showCompanyModal = false}>Abbrechen</button>
|
||||
<button class="btn btn-primary" on:click={saveCompany}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Product Modal -->
|
||||
{#if showProductModal}
|
||||
<div class="modal-overlay" on:click|self={() => showProductModal = false}>
|
||||
<div class="modal">
|
||||
<h2>{editProductId ? 'Produkt bearbeiten' : 'Neues Produkt'}</h2>
|
||||
<div class="form-group">
|
||||
<label for="productName">Produktname</label>
|
||||
<input id="productName" type="text" bind:value={formProductName} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="productPrice">Preis (Cent)</label>
|
||||
<input id="productPrice" type="number" bind:value={formProductPrice} min="0" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="productIcon">Icon</label>
|
||||
<select id="productIcon" bind:value={formProductIcon}>
|
||||
<option value="coffee">Kaffee ☕</option>
|
||||
<option value="chocolate">Kakao 🍫</option>
|
||||
<option value="tea">Tee 🍵</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" on:click={() => showProductModal = false}>Abbrechen</button>
|
||||
<button class="btn btn-primary" on:click={saveProduct}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Link Modal -->
|
||||
{#if showLinkModal}
|
||||
<div class="modal-overlay" on:click|self={() => showLinkModal = false}>
|
||||
<div class="modal">
|
||||
<h2>Neuer Zugangslink</h2>
|
||||
<div class="form-group">
|
||||
<label for="linkRole">Rolle</label>
|
||||
<select id="linkRole" bind:value={formLinkRole}>
|
||||
<option value="COMPANY_ADMIN">Firmen-Admin</option>
|
||||
<option value="PROVIDER_ADMIN">Anbieter-Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if formLinkRole === 'COMPANY_ADMIN'}
|
||||
<div class="form-group">
|
||||
<label for="linkCompany">Firma</label>
|
||||
<select id="linkCompany" bind:value={formLinkCompanyId}>
|
||||
{#each companies as c}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="form-group">
|
||||
<label for="linkDesc">Beschreibung</label>
|
||||
<input id="linkDesc" type="text" bind:value={formLinkDescription} placeholder="z.B. Admin-Zugang für Firma X" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" on:click={() => showLinkModal = false}>Abbrechen</button>
|
||||
<button class="btn btn-primary" on:click={saveLink}>Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
47
frontend/src/routes/company/[id]/+page.svelte
Normal file
47
frontend/src/routes/company/[id]/+page.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api, type Employee, type Company } from '$lib/api/client';
|
||||
|
||||
let employees: Employee[] = [];
|
||||
let companyName = '';
|
||||
let loading = true;
|
||||
|
||||
$: companyId = Number($page.params.id);
|
||||
|
||||
onMount(async () => {
|
||||
const [emps, companies] = await Promise.all([
|
||||
api.getEmployees(companyId),
|
||||
api.getCompanies()
|
||||
]);
|
||||
employees = emps;
|
||||
const company = companies.find((c: Company) => c.id === companyId);
|
||||
companyName = company?.name ?? '';
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{companyName} - Strichliste</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-header" style="position: relative;">
|
||||
<a href="/" class="back-btn" aria-label="Zurück">←</a>
|
||||
<h1>{companyName}</h1>
|
||||
<p style="color: var(--color-text-muted); margin-top: 4px;">Mitarbeiter auswählen</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div style="text-align: center; padding: 48px;">
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid" style="padding: 24px;">
|
||||
{#each employees as emp}
|
||||
<a href="/company/{companyId}/tally?employee={emp.id}" class="card">
|
||||
<div style="font-size: 2.5rem;">👤</div>
|
||||
<h3>{emp.firstName} {emp.lastName}</h3>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
107
frontend/src/routes/company/[id]/tally/+page.svelte
Normal file
107
frontend/src/routes/company/[id]/tally/+page.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api, type Product, type MonthlyTally, type Employee } from '$lib/api/client';
|
||||
|
||||
let products: Product[] = [];
|
||||
let tallies: MonthlyTally[] = [];
|
||||
let employee: Employee | null = null;
|
||||
let loading = true;
|
||||
let showToast = false;
|
||||
let toastMessage = '';
|
||||
|
||||
$: companyId = Number($page.params.id);
|
||||
$: employeeId = Number($page.url.searchParams.get('employee'));
|
||||
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
coffee: '☕',
|
||||
chocolate: '🍫',
|
||||
tea: '🍵'
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const [prods, emps] = await Promise.all([
|
||||
api.getProducts(),
|
||||
api.getEmployees(companyId)
|
||||
]);
|
||||
products = prods;
|
||||
employee = emps.find((e: Employee) => e.id === employeeId) ?? null;
|
||||
await loadTallies();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function loadTallies() {
|
||||
tallies = await api.getMonthlyTally(employeeId);
|
||||
}
|
||||
|
||||
async function addTally(productId: number, productName: string) {
|
||||
await api.createTally(employeeId, productId);
|
||||
toastMessage = `${productName} hinzugefügt!`;
|
||||
showToast = true;
|
||||
setTimeout(() => { showToast = false; }, 2000);
|
||||
await loadTallies();
|
||||
}
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return (cents / 100).toFixed(2).replace('.', ',') + ' €';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Produkt wählen - Strichliste</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-header" style="position: relative;">
|
||||
<a href="/company/{companyId}" class="back-btn" aria-label="Zurück">←</a>
|
||||
<h1>{employee ? `${employee.firstName} ${employee.lastName}` : ''}</h1>
|
||||
<p style="color: var(--color-text-muted); margin-top: 4px;">Produkt auswählen</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div style="text-align: center; padding: 48px;">
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid" style="padding: 24px;">
|
||||
{#each products as product}
|
||||
<button class="card" on:click={() => addTally(product.id, product.name)}>
|
||||
<div style="font-size: 2.5rem;">{ICON_MAP[product.iconPlaceholder] ?? '☕'}</div>
|
||||
<h3>{product.name}</h3>
|
||||
<span class="subtitle price">{formatPrice(product.priceCents)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if tallies.length > 0}
|
||||
<div style="padding: 24px;">
|
||||
<h2 style="margin-bottom: 12px; font-size: 1.2rem;">Diesen Monat</h2>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produkt</th>
|
||||
<th style="text-align: right;">Anzahl</th>
|
||||
<th style="text-align: right;">Summe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tallies as tally}
|
||||
<tr>
|
||||
<td>{tally.productName}</td>
|
||||
<td style="text-align: right;">{tally.count}×</td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(tally.totalCents)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr style="font-weight: 700;">
|
||||
<td>Gesamt</td>
|
||||
<td style="text-align: right;">{tallies.reduce((s, t) => s + t.count, 0)}×</td>
|
||||
<td style="text-align: right;" class="price">{formatPrice(tallies.reduce((s, t) => s + t.totalCents, 0))}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showToast}
|
||||
<div class="toast">✓ {toastMessage}</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user