bump
This commit is contained in:
@@ -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",
|
||||
|
||||
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 { 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user