bump
This commit is contained in:
@@ -16,7 +16,8 @@
|
|||||||
"Bash(mkdir -p backend/src/main/java/de/strichliste/resource)",
|
"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/filter)",
|
||||||
"Bash(mkdir -p backend/src/main/java/de/strichliste/dto)",
|
"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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-picker-element": "^1.26.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^2.8.0",
|
||||||
|
|||||||
71
frontend/src/lib/components/EmojiPicker.svelte
Normal file
71
frontend/src/lib/components/EmojiPicker.svelte
Normal 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>
|
||||||
76
frontend/src/lib/components/MonthPicker.svelte
Normal file
76
frontend/src/lib/components/MonthPicker.svelte
Normal 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>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { api, type Employee, type MonthlyReport, type EmployeeReportLine, type Company } from '$lib/api/client';
|
import { api, type Employee, type MonthlyReport, type EmployeeReportLine, type Company } from '$lib/api/client';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
|
import MonthPicker from '$lib/components/MonthPicker.svelte';
|
||||||
|
|
||||||
let token = '';
|
let token = '';
|
||||||
let employees: Employee[] = [];
|
let employees: Employee[] = [];
|
||||||
@@ -62,7 +63,8 @@
|
|||||||
if (company) company = { ...company, hasLogo: false };
|
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);
|
report = await api.companyAdmin.getReport(token, selectedMonth);
|
||||||
selectedEmployee = null;
|
selectedEmployee = null;
|
||||||
}
|
}
|
||||||
@@ -181,9 +183,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
<h2>Monatsauswertung</h2>
|
<h2>Monatsauswertung</h2>
|
||||||
<div class="month-selector">
|
<MonthPicker bind:value={selectedMonth} on:change={changeMonth} />
|
||||||
<input type="month" bind:value={selectedMonth} on:change={changeMonth} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if report}
|
{#if report}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { api, type Company, type Product, type ProviderReport, type AccessLink } from '$lib/api/client';
|
import { api, type Company, type Product, type ProviderReport, type AccessLink } from '$lib/api/client';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
|
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||||
|
import MonthPicker from '$lib/components/MonthPicker.svelte';
|
||||||
|
|
||||||
let token = '';
|
let token = '';
|
||||||
let activeTab: 'companies' | 'products' | 'report' | 'links' = 'companies';
|
let activeTab: 'companies' | 'products' | 'report' | 'links' = 'companies';
|
||||||
@@ -27,6 +29,15 @@
|
|||||||
let formProductPrice = 0;
|
let formProductPrice = 0;
|
||||||
let formProductIcon = 'coffee';
|
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
|
// Link Modal
|
||||||
let showLinkModal = false;
|
let showLinkModal = false;
|
||||||
let formLinkRole = 'COMPANY_ADMIN';
|
let formLinkRole = 'COMPANY_ADMIN';
|
||||||
@@ -50,7 +61,8 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadReport() {
|
async function loadReport(e?: CustomEvent<string>) {
|
||||||
|
if (e) selectedMonth = e.detail;
|
||||||
report = await api.providerAdmin.getReport(token, selectedMonth);
|
report = await api.providerAdmin.getReport(token, selectedMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,9 +264,7 @@
|
|||||||
{#if activeTab === 'report'}
|
{#if activeTab === 'report'}
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
<h2>Gesamtauswertung</h2>
|
<h2>Gesamtauswertung</h2>
|
||||||
<div class="month-selector">
|
<MonthPicker bind:value={selectedMonth} on:change={loadReport} />
|
||||||
<input type="month" bind:value={selectedMonth} on:change={loadReport} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{#if report}
|
{#if report}
|
||||||
{#each report.companies as companyReport}
|
{#each report.companies as companyReport}
|
||||||
@@ -299,7 +309,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each accessLinks as link}
|
{#each accessLinks as link}
|
||||||
@@ -311,7 +321,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -352,12 +376,8 @@
|
|||||||
<input id="productPrice" type="number" bind:value={formProductPrice} min="0" />
|
<input id="productPrice" type="number" bind:value={formProductPrice} min="0" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="productIcon">Icon</label>
|
<label>Icon</label>
|
||||||
<select id="productIcon" bind:value={formProductIcon}>
|
<EmojiPicker value={formProductIcon} on:select={(e) => formProductIcon = e.detail} />
|
||||||
<option value="coffee">Kaffee ☕</option>
|
|
||||||
<option value="chocolate">Kakao 🍫</option>
|
|
||||||
<option value="tea">Tee 🍵</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" on:click={() => showProductModal = false}>Abbrechen</button>
|
<button class="btn btn-secondary" on:click={() => showProductModal = false}>Abbrechen</button>
|
||||||
@@ -400,3 +420,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
|
|||||||
@@ -15,12 +15,16 @@
|
|||||||
$: companyId = Number($page.params.id);
|
$: companyId = Number($page.params.id);
|
||||||
$: employeeId = Number($page.url.searchParams.get('employee'));
|
$: employeeId = Number($page.url.searchParams.get('employee'));
|
||||||
|
|
||||||
const ICON_MAP: Record<string, string> = {
|
const LEGACY_ICON_MAP: Record<string, string> = {
|
||||||
coffee: '☕',
|
coffee: '☕',
|
||||||
chocolate: '🍫',
|
chocolate: '🍫',
|
||||||
tea: '🍵'
|
tea: '🍵'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getIcon(placeholder: string): string {
|
||||||
|
return LEGACY_ICON_MAP[placeholder] ?? placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const [prods, emps] = await Promise.all([
|
const [prods, emps] = await Promise.all([
|
||||||
api.getProducts(),
|
api.getProducts(),
|
||||||
@@ -68,7 +72,7 @@
|
|||||||
<div class="card-grid" style="padding: 24px;">
|
<div class="card-grid" style="padding: 24px;">
|
||||||
{#each products as product}
|
{#each products as product}
|
||||||
<button class="card" on:click={() => addTally(product.id, product.name)}>
|
<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>
|
<h3>{product.name}</h3>
|
||||||
<span class="subtitle price">{formatPrice(product.priceCents)}</span>
|
<span class="subtitle price">{formatPrice(product.priceCents)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user