This commit is contained in:
2026-03-31 15:52:34 +02:00
parent f76590d84c
commit 0f05e1c15b
7 changed files with 228 additions and 19 deletions

View File

@@ -16,7 +16,8 @@
"Bash(mkdir -p backend/src/main/java/de/strichliste/resource)",
"Bash(mkdir -p backend/src/main/java/de/strichliste/filter)",
"Bash(mkdir -p backend/src/main/java/de/strichliste/dto)",
"Bash(mkdir -p backend/src/main/resources/db/migration)"
"Bash(mkdir -p backend/src/main/resources/db/migration)",
"Bash(npm install:*)"
]
}
}

View File

@@ -8,6 +8,9 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"emoji-picker-element": "^1.26.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0",

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
export let value = '☕';
const dispatch = createEventDispatcher<{ select: string }>();
let container: HTMLDivElement;
onMount(async () => {
// Dynamisch laden, da es ein Browser-only Web Component ist
await import('emoji-picker-element');
const picker = document.createElement('emoji-picker') as HTMLElement & {
addEventListener: (event: string, handler: (e: CustomEvent) => void) => void;
};
picker.setAttribute('class', 'emoji-picker-custom');
picker.addEventListener('emoji-click', (e: CustomEvent) => {
dispatch('select', e.detail.unicode);
});
container.appendChild(picker);
// Dark-Mode Styles für den Picker setzen
const style = document.createElement('style');
style.textContent = `
emoji-picker.emoji-picker-custom {
--background: #0d2440;
--border-color: #1a3a5c;
--button-hover-background: #122e52;
--category-emoji-padding: 0.5rem;
--emoji-padding: 0.35rem;
--input-border-color: #1a3a5c;
--input-font-color: #eaeaea;
--input-placeholder-color: #8aaabf;
--outline-color: #00b4c8;
--category-font-color: #8aaabf;
width: 100%;
height: 320px;
}
`;
container.appendChild(style);
});
</script>
<div class="picker-wrapper">
<div class="selected-preview">
Ausgewählt: <span class="selected-emoji">{value}</span>
</div>
<div bind:this={container}></div>
</div>
<style>
.picker-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.selected-preview {
font-size: 0.9rem;
color: var(--color-text-muted);
}
.selected-emoji {
font-size: 1.4rem;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
// value as "YYYY-MM"
export let value: string = new Date().toISOString().slice(0, 7);
const dispatch = createEventDispatcher<{ change: string }>();
const MONTHS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
$: [year, month] = value.split('-').map(Number);
$: label = `${MONTHS[month - 1]} ${year}`;
function prev() {
const d = new Date(year, month - 2, 1);
emit(d);
}
function next() {
const d = new Date(year, month, 1);
emit(d);
}
function emit(d: Date) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
value = `${y}-${m}`;
dispatch('change', value);
}
</script>
<div class="month-picker">
<button class="nav-btn" on:click={prev} aria-label="Vorheriger Monat"></button>
<span class="month-label">{label}</span>
<button class="nav-btn" on:click={next} aria-label="Nächster Monat"></button>
</div>
<style>
.month-picker {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 4px;
}
.nav-btn {
background: none;
border: none;
color: var(--color-text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 10px;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.nav-btn:hover {
background: var(--color-bg-card);
}
.month-label {
min-width: 140px;
text-align: center;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
user-select: none;
}
</style>

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { api, type Employee, type MonthlyReport, type EmployeeReportLine, type Company } from '$lib/api/client';
import Header from '$lib/components/Header.svelte';
import MonthPicker from '$lib/components/MonthPicker.svelte';
let token = '';
let employees: Employee[] = [];
@@ -62,7 +63,8 @@
if (company) company = { ...company, hasLogo: false };
}
async function changeMonth() {
async function changeMonth(e?: CustomEvent<string>) {
if (e) selectedMonth = e.detail;
report = await api.companyAdmin.getReport(token, selectedMonth);
selectedEmployee = null;
}
@@ -181,9 +183,7 @@
<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>
<MonthPicker bind:value={selectedMonth} on:change={changeMonth} />
</div>
{#if report}

View File

@@ -3,6 +3,8 @@
import { page } from '$app/stores';
import { api, type Company, type Product, type ProviderReport, type AccessLink } from '$lib/api/client';
import Header from '$lib/components/Header.svelte';
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
import MonthPicker from '$lib/components/MonthPicker.svelte';
let token = '';
let activeTab: 'companies' | 'products' | 'report' | 'links' = 'companies';
@@ -27,6 +29,15 @@
let formProductPrice = 0;
let formProductIcon = 'coffee';
// Copy feedback
let copiedLinkId: number | null = null;
async function copyLink(link: AccessLink) {
await navigator.clipboard.writeText(buildLink(link));
copiedLinkId = link.id;
setTimeout(() => { copiedLinkId = null; }, 2000);
}
// Link Modal
let showLinkModal = false;
let formLinkRole = 'COMPANY_ADMIN';
@@ -50,7 +61,8 @@
loading = false;
}
async function loadReport() {
async function loadReport(e?: CustomEvent<string>) {
if (e) selectedMonth = e.detail;
report = await api.providerAdmin.getReport(token, selectedMonth);
}
@@ -252,9 +264,7 @@
{#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>
<MonthPicker bind:value={selectedMonth} on:change={loadReport} />
</div>
{#if report}
{#each report.companies as companyReport}
@@ -299,7 +309,7 @@
</div>
<table class="admin-table">
<thead>
<tr><th>Beschreibung</th><th>Rolle</th><th>Link</th></tr>
<tr><th>Beschreibung</th><th>Rolle</th><th>Firma</th><th>Link</th></tr>
</thead>
<tbody>
{#each accessLinks as link}
@@ -311,7 +321,21 @@
</span>
</td>
<td>
<code style="font-size: 0.8rem; word-break: break-all;">{buildLink(link)}</code>
{#if link.companyId}
{companies.find(c => c.id === link.companyId)?.name ?? '-'}
{:else}
-
{/if}
</td>
<td>
<button
class="link-copy-btn"
on:click={() => copyLink(link)}
title="Klicken zum Kopieren"
>
<code>{buildLink(link)}</code>
<span class="copy-icon">{copiedLinkId === link.id ? '✓' : '⎘'}</span>
</button>
</td>
</tr>
{/each}
@@ -352,12 +376,8 @@
<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>
<label>Icon</label>
<EmojiPicker value={formProductIcon} on:select={(e) => formProductIcon = e.detail} />
</div>
<div class="modal-actions">
<button class="btn btn-secondary" on:click={() => showProductModal = false}>Abbrechen</button>
@@ -400,3 +420,37 @@
</div>
</div>
{/if}
<style>
.link-copy-btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 6px 10px;
cursor: pointer;
text-align: left;
color: var(--color-text);
transition: border-color 0.2s, background 0.2s;
max-width: 420px;
}
.link-copy-btn:hover {
border-color: var(--color-teal);
background: rgba(0, 180, 200, 0.06);
}
.link-copy-btn code {
font-size: 0.78rem;
word-break: break-all;
flex: 1;
}
.copy-icon {
font-size: 1rem;
flex-shrink: 0;
color: var(--color-teal);
}
</style>

View File

@@ -15,12 +15,16 @@
$: companyId = Number($page.params.id);
$: employeeId = Number($page.url.searchParams.get('employee'));
const ICON_MAP: Record<string, string> = {
const LEGACY_ICON_MAP: Record<string, string> = {
coffee: '☕',
chocolate: '🍫',
tea: '🍵'
};
function getIcon(placeholder: string): string {
return LEGACY_ICON_MAP[placeholder] ?? placeholder;
}
onMount(async () => {
const [prods, emps] = await Promise.all([
api.getProducts(),
@@ -68,7 +72,7 @@
<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>
<div style="font-size: 2.5rem;">{getIcon(product.iconPlaceholder)}</div>
<h3>{product.name}</h3>
<span class="subtitle price">{formatPrice(product.priceCents)}</span>
</button>