Toten Code entfernt
Nie importierte Dateien (rund 1.900 Zeilen): die alte Ticketzeile TicketRow mit ihren Hilfsdialogen EditWorksheetTimeModal und ResponseDropdown, TicketFilters, WebpageProjectPanel, ModernSidebar (+ lib/utils), die three.js-Animation PixelBlast und createDummyTicket. Dazu die Helfer, die nur diese Dateien benutzt haben (isTemplateProject/getAvailableProjects, WEBPAGE_TICKET_TYPE, PREVIEW_TEMPLATE_NAME), die nicht existierenden Collections users und attachments, getWorksheetsByEmployee und der nur von TicketRow benutzte zweite "Zeit nachtragen"-Weg in der Arbeitsblatt-Liste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d2a99e5d6
commit
d684aea576
15 files changed
+3
-1999
No files matched your search
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { FaClock } from 'react-icons/fa'
|
||||
|
||||
/** Minuten zwischen hhmm-Zeiten, Übernacht (+24h) wie useWorksheets.calculateTime. */
|
||||
function calculateMinutes(startTime, endTime) {
|
||||
if (!startTime || !endTime) return null
|
||||
const sh = parseInt(startTime.substring(0, 2), 10)
|
||||
const sm = parseInt(startTime.substring(2, 4), 10)
|
||||
const eh = parseInt(endTime.substring(0, 2), 10)
|
||||
const em = parseInt(endTime.substring(2, 4), 10)
|
||||
if ([sh, sm, eh, em].some(Number.isNaN)) return null
|
||||
let diff = eh * 60 + em - (sh * 60 + sm)
|
||||
if (diff < 0) diff += 24 * 60
|
||||
return diff
|
||||
}
|
||||
|
||||
function formatMinutes(minutes) {
|
||||
if (minutes === null || minutes === undefined) return '–'
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return h > 0 ? `${h}h ${m}min` : `${m}min`
|
||||
}
|
||||
|
||||
/**
|
||||
* Startzeit fuer Git-Push-Worksheets nachtragen: Die Endzeit wurde beim Push
|
||||
* automatisch gesetzt, hier wird der Arbeitsbeginn erfasst. Beim Speichern
|
||||
* wird totalTime berechnet und isComment auf false gesetzt, damit der Eintrag
|
||||
* in der Arbeitszeit-Statistik zaehlt.
|
||||
*/
|
||||
export default function EditWorksheetTimeModal({ isOpen, onClose, worksheet, onSave }) {
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [startTime, setStartTime] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && worksheet) {
|
||||
setStartDate(worksheet.startDate || worksheet.endDate || '')
|
||||
setStartTime(worksheet.startTime || '')
|
||||
setError('')
|
||||
}
|
||||
}, [isOpen, worksheet])
|
||||
|
||||
const totalTime = useMemo(
|
||||
() => calculateMinutes(startTime, worksheet?.endTime),
|
||||
[startTime, worksheet]
|
||||
)
|
||||
|
||||
if (!isOpen || !worksheet) return null
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(startDate)) {
|
||||
setError('Startdatum bitte als TT.MM.JJJJ angeben.')
|
||||
return
|
||||
}
|
||||
if (!/^\d{4}$/.test(startTime)) {
|
||||
setError('Startzeit bitte als HHMM angeben (z.B. 0930).')
|
||||
return
|
||||
}
|
||||
if (totalTime === null) {
|
||||
setError('Zeit konnte nicht berechnet werden.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError('')
|
||||
const result = await onSave(worksheet, {
|
||||
startDate,
|
||||
startTime,
|
||||
totalTime,
|
||||
isComment: false,
|
||||
})
|
||||
setSaving(false)
|
||||
if (result?.success) {
|
||||
onClose()
|
||||
} else {
|
||||
setError(result?.error || 'Speichern fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={onClose}>
|
||||
×
|
||||
</span>
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">
|
||||
<FaClock style={{ marginRight: 8 }} />
|
||||
Zeit nachtragen – WSID {worksheet.wsid}
|
||||
</h2>
|
||||
<p className="text-grey" style={{ fontSize: '14px' }}>
|
||||
Die Endzeit wurde beim Git-Push automatisch gesetzt
|
||||
({worksheet.endDate} {worksheet.endTime ? `${worksheet.endTime.substring(0, 2)}:${worksheet.endTime.substring(2, 4)}` : ''}).
|
||||
Trage hier ein, wann die Arbeit begonnen hat.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Startdatum (TT.MM.JJJJ)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
placeholder="11.07.2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Startzeit (HHMM)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value.replace(/[^0-9]/g, '').slice(0, 4))}
|
||||
placeholder="0930"
|
||||
maxLength={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Berechnete Arbeitszeit</label>
|
||||
<div style={{ fontSize: '18px', fontWeight: 600, color: '#10b981' }}>
|
||||
{formatMinutes(totalTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<button type="submit" className="btn btn-green" disabled={saving}>
|
||||
{saving ? 'Speichert...' : 'Zeit speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/* Modern Sidebar Styles */
|
||||
|
||||
.modern-sidebar-desktop {
|
||||
height: 100vh;
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: var(--sidebar-bg, #f5f5f5);
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modern-sidebar-desktop {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Sidebar */
|
||||
.modern-sidebar-mobile-header {
|
||||
height: 60px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--sidebar-bg, #f5f5f5);
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modern-sidebar-mobile-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-toggle {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-toggle svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-menu {
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
inset: 0;
|
||||
background: var(--sidebar-bg, #fff);
|
||||
padding: 2.5rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-close {
|
||||
position: absolute;
|
||||
right: 2.5rem;
|
||||
top: 2.5rem;
|
||||
z-index: 50;
|
||||
cursor: pointer;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-close svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Sidebar Link */
|
||||
.modern-sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
color: var(--text-color, #333);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modern-sidebar-link:hover {
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.modern-sidebar-link-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modern-sidebar-link-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.modern-sidebar-link-label {
|
||||
font-size: 0.875rem;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.modern-sidebar-desktop,
|
||||
.modern-sidebar-mobile-header,
|
||||
.modern-sidebar-mobile-menu {
|
||||
background: var(--dark-sidebar-bg, #1a1a1a);
|
||||
border-color: var(--dark-border-color, #333);
|
||||
}
|
||||
|
||||
.modern-sidebar-link {
|
||||
color: var(--dark-text-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.modern-sidebar-link:hover {
|
||||
background: var(--dark-hover-bg, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-toggle,
|
||||
.modern-sidebar-mobile-close {
|
||||
color: var(--dark-text-color, #e0e0e0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState, createContext, useContext } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { IconMenu2, IconX } from '@tabler/icons-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import './ModernSidebar.css'
|
||||
|
||||
const SidebarContext = createContext(undefined)
|
||||
|
||||
export const useSidebar = () => {
|
||||
const context = useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const SidebarProvider = ({ children, open: openProp, setOpen: setOpenProp, animate = true }) => {
|
||||
const [openState, setOpenState] = useState(false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ open, setOpen, animate }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const Sidebar = ({ children, open, setOpen, animate }) => {
|
||||
return (
|
||||
<SidebarProvider open={open} setOpen={setOpen} animate={animate}>
|
||||
{children}
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const SidebarBody = ({ className, children, ...props }) => {
|
||||
return (
|
||||
<>
|
||||
<DesktopSidebar className={className} {...props}>{children}</DesktopSidebar>
|
||||
<MobileSidebar className={className}>{children}</MobileSidebar>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const DesktopSidebar = ({ className, children, ...props }) => {
|
||||
const { open, setOpen, animate } = useSidebar()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn('modern-sidebar-desktop', className)}
|
||||
animate={{
|
||||
width: animate ? (open ? '300px' : '60px') : '300px',
|
||||
}}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MobileSidebar = ({ className, children }) => {
|
||||
const { open, setOpen } = useSidebar()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modern-sidebar-mobile-header">
|
||||
<div className="modern-sidebar-mobile-toggle">
|
||||
<IconMenu2 onClick={() => setOpen(!open)} />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ x: '-100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: '-100%', opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className={cn('modern-sidebar-mobile-menu', className)}
|
||||
>
|
||||
<div className="modern-sidebar-mobile-close" onClick={() => setOpen(!open)}>
|
||||
<IconX />
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const SidebarLink = ({ link, className, ...props }) => {
|
||||
const { open, animate } = useSidebar()
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
className={cn('modern-sidebar-link', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="modern-sidebar-link-icon">{link.icon}</span>
|
||||
<motion.span
|
||||
animate={{
|
||||
display: animate ? (open ? 'inline-block' : 'none') : 'inline-block',
|
||||
opacity: animate ? (open ? 1 : 0) : 1,
|
||||
}}
|
||||
className="modern-sidebar-link-label"
|
||||
>
|
||||
{link.label}
|
||||
</motion.span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.pixel-blast-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
|
||||
import './PixelBlast.css';
|
||||
|
||||
const createTouchTexture = () => {
|
||||
const size = 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('2D context not available');
|
||||
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const texture = new THREE.Texture(canvas);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = false;
|
||||
|
||||
const trail = [];
|
||||
let last = null;
|
||||
const maxAge = 64;
|
||||
let radius = 0.1 * size;
|
||||
const speed = 1 / maxAge;
|
||||
|
||||
const clear = () => {
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
const drawPoint = p => {
|
||||
const pos = { x: p.x * size, y: (1 - p.y) * size };
|
||||
let intensity = 1;
|
||||
|
||||
const easeOutSine = t => Math.sin((t * Math.PI) / 2);
|
||||
const easeOutQuad = t => -t * (t - 2);
|
||||
|
||||
if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
|
||||
else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
|
||||
|
||||
intensity *= p.force;
|
||||
|
||||
const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
|
||||
const offset = size * 5;
|
||||
ctx.shadowOffsetX = offset;
|
||||
ctx.shadowOffsetY = offset;
|
||||
ctx.shadowBlur = radius;
|
||||
ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = 'rgba(255,0,0,1)';
|
||||
ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
const addTouch = norm => {
|
||||
let force = 0;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
if (last) {
|
||||
const dx = norm.x - last.x;
|
||||
const dy = norm.y - last.y;
|
||||
if (dx === 0 && dy === 0) return;
|
||||
|
||||
const dd = dx * dx + dy * dy;
|
||||
const d = Math.sqrt(dd);
|
||||
vx = dx / (d || 1);
|
||||
vy = dy / (d || 1);
|
||||
force = Math.min(dd * 10000, 1);
|
||||
}
|
||||
|
||||
last = { x: norm.x, y: norm.y };
|
||||
trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
clear();
|
||||
for (let i = trail.length - 1; i >= 0; i--) {
|
||||
const point = trail[i];
|
||||
const f = point.force * speed * (1 - point.age / maxAge);
|
||||
point.x += point.vx * f;
|
||||
point.y += point.vy * f;
|
||||
point.age++;
|
||||
if (point.age > maxAge) trail.splice(i, 1);
|
||||
}
|
||||
for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
|
||||
return {
|
||||
canvas,
|
||||
texture,
|
||||
addTouch,
|
||||
update,
|
||||
set radiusScale(v) {
|
||||
radius = 0.1 * size * v;
|
||||
},
|
||||
get radiusScale() {
|
||||
return radius / (0.1 * size);
|
||||
},
|
||||
size
|
||||
};
|
||||
};
|
||||
|
||||
const createLiquidEffect = (texture, opts) => {
|
||||
const fragment = `
|
||||
uniform sampler2D uTexture;
|
||||
uniform float uStrength;
|
||||
uniform float uTime;
|
||||
uniform float uFreq;
|
||||
|
||||
void mainUv(inout vec2 uv) {
|
||||
vec4 tex = texture2D(uTexture, uv);
|
||||
float vx = tex.r * 2.0 - 1.0;
|
||||
float vy = tex.g * 2.0 - 1.0;
|
||||
float intensity = tex.b;
|
||||
float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
|
||||
float amt = uStrength * intensity * wave;
|
||||
uv += vec2(vx, vy) * amt;
|
||||
}
|
||||
`;
|
||||
|
||||
return new Effect('LiquidEffect', fragment, {
|
||||
uniforms: new Map([
|
||||
['uTexture', new THREE.Uniform(texture)],
|
||||
['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
|
||||
['uTime', new THREE.Uniform(0)],
|
||||
['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)]
|
||||
])
|
||||
});
|
||||
};
|
||||
|
||||
const SHAPE_MAP = {
|
||||
square: 0,
|
||||
circle: 1,
|
||||
triangle: 2,
|
||||
diamond: 3
|
||||
};
|
||||
|
||||
const VERTEX_SRC = `
|
||||
void main() {
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SRC = `
|
||||
precision highp float;
|
||||
|
||||
uniform vec3 uColor;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uTime;
|
||||
uniform float uPixelSize;
|
||||
uniform float uScale;
|
||||
uniform float uDensity;
|
||||
uniform float uPixelJitter;
|
||||
uniform int uEnableRipples;
|
||||
uniform float uRippleSpeed;
|
||||
uniform float uRippleThickness;
|
||||
uniform float uRippleIntensity;
|
||||
uniform float uEdgeFade;
|
||||
uniform int uShapeType;
|
||||
|
||||
const int SHAPE_SQUARE = 0;
|
||||
const int SHAPE_CIRCLE = 1;
|
||||
const int SHAPE_TRIANGLE = 2;
|
||||
const int SHAPE_DIAMOND = 3;
|
||||
|
||||
const int MAX_CLICKS = 10;
|
||||
uniform vec2 uClickPos [MAX_CLICKS];
|
||||
uniform float uClickTimes[MAX_CLICKS];
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
float Bayer2(vec2 a) {
|
||||
a = floor(a);
|
||||
return fract(a.x / 2. + a.y * a.y * .75);
|
||||
}
|
||||
|
||||
#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
|
||||
#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
|
||||
|
||||
#define FBM_OCTAVES 5
|
||||
#define FBM_LACUNARITY 1.25
|
||||
#define FBM_GAIN 1.0
|
||||
|
||||
float hash11(float n){ return fract(sin(n)*43758.5453); }
|
||||
|
||||
float vnoise(vec3 p){
|
||||
vec3 ip = floor(p);
|
||||
vec3 fp = fract(p);
|
||||
float n000 = hash11(dot(ip + vec3(0.0,0.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n100 = hash11(dot(ip + vec3(1.0,0.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n010 = hash11(dot(ip + vec3(0.0,1.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n110 = hash11(dot(ip + vec3(1.0,1.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n001 = hash11(dot(ip + vec3(0.0,0.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n101 = hash11(dot(ip + vec3(1.0,0.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n011 = hash11(dot(ip + vec3(0.0,1.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n111 = hash11(dot(ip + vec3(1.0,1.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
|
||||
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
|
||||
|
||||
float x00 = mix(n000, n100, w.x);
|
||||
float x10 = mix(n010, n110, w.x);
|
||||
float x01 = mix(n001, n101, w.x);
|
||||
float x11 = mix(n011, n111, w.x);
|
||||
|
||||
float y0 = mix(x00, x10, w.y);
|
||||
float y1 = mix(x01, x11, w.y);
|
||||
|
||||
return mix(y0, y1, w.z) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
float fbm2(vec2 uv, float t){
|
||||
vec3 p = vec3(uv * uScale, t);
|
||||
float amp = 1.0;
|
||||
float freq = 1.0;
|
||||
float sum = 1.0;
|
||||
|
||||
for (int i = 0; i < FBM_OCTAVES; ++i){
|
||||
sum += amp * vnoise(p * freq);
|
||||
freq *= FBM_LACUNARITY;
|
||||
amp *= FBM_GAIN;
|
||||
}
|
||||
|
||||
return sum * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
float maskCircle(vec2 p, float cov){
|
||||
float r = sqrt(cov) * .25;
|
||||
float d = length(p - 0.5) - r;
|
||||
float aa = 0.5 * fwidth(d);
|
||||
return cov * (1.0 - smoothstep(-aa, aa, d * 2.0));
|
||||
}
|
||||
|
||||
float maskTriangle(vec2 p, vec2 id, float cov){
|
||||
bool flip = mod(id.x + id.y, 2.0) > 0.5;
|
||||
if (flip) p.x = 1.0 - p.x;
|
||||
|
||||
float r = sqrt(cov);
|
||||
float d = p.y - r*(1.0 - p.x);
|
||||
float aa = fwidth(d);
|
||||
|
||||
return cov * clamp(0.5 - d/aa, 0.0, 1.0);
|
||||
}
|
||||
|
||||
float maskDiamond(vec2 p, float cov){
|
||||
float r = sqrt(cov) * 0.564;
|
||||
return step(abs(p.x - 0.49) + abs(p.y - 0.49), r);
|
||||
}
|
||||
|
||||
void main(){
|
||||
float pixelSize = uPixelSize;
|
||||
|
||||
vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
|
||||
float aspectRatio = uResolution.x / uResolution.y;
|
||||
|
||||
vec2 pixelId = floor(fragCoord / pixelSize);
|
||||
vec2 pixelUV = fract(fragCoord / pixelSize);
|
||||
|
||||
float cellPixelSize = 8.0 * pixelSize;
|
||||
vec2 cellId = floor(fragCoord / cellPixelSize);
|
||||
vec2 cellCoord = cellId * cellPixelSize;
|
||||
|
||||
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
|
||||
|
||||
float base = fbm2(uv, uTime * 0.05);
|
||||
base = base * 0.5 - 0.65;
|
||||
|
||||
float feed = base + (uDensity - 0.5) * 0.3;
|
||||
|
||||
float speed = uRippleSpeed;
|
||||
float thickness = uRippleThickness;
|
||||
const float dampT = 1.0;
|
||||
const float dampR = 10.0;
|
||||
|
||||
if (uEnableRipples == 1) {
|
||||
for (int i = 0; i < MAX_CLICKS; ++i){
|
||||
vec2 pos = uClickPos[i];
|
||||
if (pos.x < 0.0) continue;
|
||||
|
||||
float cellPixelSize = 8.0 * pixelSize;
|
||||
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
|
||||
|
||||
float t = max(uTime - uClickTimes[i], 0.0);
|
||||
float r = distance(uv, cuv);
|
||||
|
||||
float waveR = speed * t;
|
||||
float ring = exp(-pow((r - waveR) / thickness, 2.0));
|
||||
float atten = exp(-dampT * t) * exp(-dampR * r);
|
||||
|
||||
feed = max(feed, ring * atten * uRippleIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
|
||||
float bw = step(0.5, feed + bayer);
|
||||
|
||||
float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
|
||||
float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
|
||||
float coverage = bw * jitterScale;
|
||||
|
||||
float M;
|
||||
if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
|
||||
else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
|
||||
else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
|
||||
else M = coverage;
|
||||
|
||||
if (uEdgeFade > 0.0) {
|
||||
vec2 norm = gl_FragCoord.xy / uResolution;
|
||||
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
|
||||
float fade = smoothstep(0.0, uEdgeFade, edge);
|
||||
M *= fade;
|
||||
}
|
||||
|
||||
vec3 color = uColor;
|
||||
fragColor = vec4(color, M);
|
||||
}
|
||||
`;
|
||||
|
||||
const MAX_CLICKS = 10;
|
||||
|
||||
const PixelBlast = ({
|
||||
variant = 'square',
|
||||
pixelSize = 3,
|
||||
color = '#10b981',
|
||||
className,
|
||||
style,
|
||||
antialias = true,
|
||||
patternScale = 2,
|
||||
patternDensity = 1,
|
||||
liquid = false,
|
||||
liquidStrength = 0.1,
|
||||
liquidRadius = 1,
|
||||
pixelSizeJitter = 0,
|
||||
enableRipples = true,
|
||||
rippleIntensityScale = 1,
|
||||
rippleThickness = 0.1,
|
||||
rippleSpeed = 0.3,
|
||||
liquidWobbleSpeed = 4.5,
|
||||
autoPauseOffscreen = true,
|
||||
speed = 0.5,
|
||||
transparent = true,
|
||||
edgeFade = 0.5,
|
||||
noiseAmount = 0
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const visibilityRef = useRef({ visible: true });
|
||||
const speedRef = useRef(speed);
|
||||
const threeRef = useRef(null);
|
||||
const prevConfigRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
speedRef.current = speed;
|
||||
|
||||
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
|
||||
const cfg = { antialias, liquid, noiseAmount };
|
||||
let mustReinit = false;
|
||||
|
||||
if (!threeRef.current) mustReinit = true;
|
||||
else if (prevConfigRef.current) {
|
||||
for (const k of needsReinitKeys)
|
||||
if (prevConfigRef.current[k] !== cfg[k]) {
|
||||
mustReinit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mustReinit) {
|
||||
if (threeRef.current) {
|
||||
const t = threeRef.current;
|
||||
t.resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(t.raf);
|
||||
t.quad?.geometry.dispose();
|
||||
t.material.dispose();
|
||||
t.composer?.dispose();
|
||||
t.renderer.dispose();
|
||||
if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
|
||||
threeRef.current = null;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance'
|
||||
});
|
||||
|
||||
renderer.domElement.style.width = '100%';
|
||||
renderer.domElement.style.height = '100%';
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
if (transparent) renderer.setClearAlpha(0);
|
||||
else renderer.setClearColor(0x000000, 1);
|
||||
|
||||
const uniforms = {
|
||||
uResolution: { value: new THREE.Vector2(0, 0) },
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color(color) },
|
||||
uClickPos: {
|
||||
value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1))
|
||||
},
|
||||
uClickTimes: { value: new Float32Array(MAX_CLICKS) },
|
||||
uShapeType: { value: SHAPE_MAP[variant] ?? 0 },
|
||||
uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
|
||||
uScale: { value: patternScale },
|
||||
uDensity: { value: patternDensity },
|
||||
uPixelJitter: { value: pixelSizeJitter },
|
||||
uEnableRipples: { value: enableRipples ? 1 : 0 },
|
||||
uRippleSpeed: { value: rippleSpeed },
|
||||
uRippleThickness: { value: rippleThickness },
|
||||
uRippleIntensity: { value: rippleIntensityScale },
|
||||
uEdgeFade: { value: edgeFade }
|
||||
};
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
vertexShader: VERTEX_SRC,
|
||||
fragmentShader: FRAGMENT_SRC,
|
||||
uniforms,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
glslVersion: THREE.GLSL3
|
||||
});
|
||||
|
||||
const quadGeom = new THREE.PlaneGeometry(2, 2);
|
||||
const quad = new THREE.Mesh(quadGeom, material);
|
||||
scene.add(quad);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
|
||||
const setSize = () => {
|
||||
const w = container.clientWidth || 1;
|
||||
const h = container.clientHeight || 1;
|
||||
renderer.setSize(w, h, false);
|
||||
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
|
||||
if (threeRef.current?.composer)
|
||||
threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
|
||||
uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
|
||||
};
|
||||
|
||||
setSize();
|
||||
|
||||
const ro = new ResizeObserver(setSize);
|
||||
ro.observe(container);
|
||||
|
||||
const randomFloat = () => {
|
||||
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
|
||||
const u32 = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(u32);
|
||||
return u32[0] / 0xffffffff;
|
||||
}
|
||||
return Math.random();
|
||||
};
|
||||
|
||||
const timeOffset = randomFloat() * 1000;
|
||||
|
||||
let composer;
|
||||
let touch;
|
||||
let liquidEffect;
|
||||
|
||||
if (liquid) {
|
||||
touch = createTouchTexture();
|
||||
touch.radiusScale = liquidRadius;
|
||||
|
||||
composer = new EffectComposer(renderer);
|
||||
const renderPass = new RenderPass(scene, camera);
|
||||
|
||||
liquidEffect = createLiquidEffect(touch.texture, {
|
||||
strength: liquidStrength,
|
||||
freq: liquidWobbleSpeed
|
||||
});
|
||||
|
||||
const effectPass = new EffectPass(camera, liquidEffect);
|
||||
effectPass.renderToScreen = true;
|
||||
|
||||
composer.addPass(renderPass);
|
||||
composer.addPass(effectPass);
|
||||
}
|
||||
|
||||
if (noiseAmount > 0) {
|
||||
if (!composer) {
|
||||
composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
}
|
||||
|
||||
const noiseEffect = new Effect(
|
||||
'NoiseEffect',
|
||||
`uniform float uTime; uniform float uAmount; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453);} void mainUv(inout vec2 uv){} void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor){ float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); float g=(n-0.5)*uAmount; outputColor=inputColor+vec4(vec3(g),0.0);} `,
|
||||
{
|
||||
uniforms: new Map([
|
||||
['uTime', new THREE.Uniform(0)],
|
||||
['uAmount', new THREE.Uniform(noiseAmount)]
|
||||
])
|
||||
}
|
||||
);
|
||||
|
||||
const noisePass = new EffectPass(camera, noiseEffect);
|
||||
noisePass.renderToScreen = true;
|
||||
|
||||
if (composer && composer.passes.length > 0) composer.passes.forEach(p => (p.renderToScreen = false));
|
||||
composer.addPass(noisePass);
|
||||
}
|
||||
|
||||
if (composer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
|
||||
|
||||
const mapToPixels = e => {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const scaleX = renderer.domElement.width / rect.width;
|
||||
const scaleY = renderer.domElement.height / rect.height;
|
||||
const fx = (e.clientX - rect.left) * scaleX;
|
||||
const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
|
||||
return {
|
||||
fx,
|
||||
fy,
|
||||
w: renderer.domElement.width,
|
||||
h: renderer.domElement.height
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerDown = e => {
|
||||
const { fx, fy } = mapToPixels(e);
|
||||
const ix = threeRef.current?.clickIx ?? 0;
|
||||
uniforms.uClickPos.value[ix].set(fx, fy);
|
||||
uniforms.uClickTimes.value[ix] = uniforms.uTime.value;
|
||||
if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
|
||||
};
|
||||
|
||||
const onPointerMove = e => {
|
||||
if (!touch) return;
|
||||
const { fx, fy, w, h } = mapToPixels(e);
|
||||
touch.addTouch({ x: fx / w, y: fy / h });
|
||||
};
|
||||
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown, {
|
||||
passive: true
|
||||
});
|
||||
renderer.domElement.addEventListener('pointermove', onPointerMove, {
|
||||
passive: true
|
||||
});
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const animate = () => {
|
||||
if (autoPauseOffscreen && !visibilityRef.current.visible) {
|
||||
raf = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
|
||||
if (liquidEffect) liquidEffect.uniforms.get('uTime').value = uniforms.uTime.value;
|
||||
|
||||
if (composer) {
|
||||
if (touch) touch.update();
|
||||
composer.passes.forEach(p => {
|
||||
const effs = p.effects;
|
||||
if (effs)
|
||||
effs.forEach(eff => {
|
||||
const u = eff.uniforms?.get('uTime');
|
||||
if (u) u.value = uniforms.uTime.value;
|
||||
});
|
||||
});
|
||||
composer.render();
|
||||
} else renderer.render(scene, camera);
|
||||
|
||||
raf = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(animate);
|
||||
|
||||
threeRef.current = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
material,
|
||||
clock,
|
||||
clickIx: 0,
|
||||
uniforms,
|
||||
resizeObserver: ro,
|
||||
raf,
|
||||
quad,
|
||||
timeOffset,
|
||||
composer,
|
||||
touch,
|
||||
liquidEffect
|
||||
};
|
||||
} else {
|
||||
const t = threeRef.current;
|
||||
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
|
||||
t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
|
||||
t.uniforms.uColor.value.set(color);
|
||||
t.uniforms.uScale.value = patternScale;
|
||||
t.uniforms.uDensity.value = patternDensity;
|
||||
t.uniforms.uPixelJitter.value = pixelSizeJitter;
|
||||
t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
|
||||
t.uniforms.uRippleIntensity.value = rippleIntensityScale;
|
||||
t.uniforms.uRippleThickness.value = rippleThickness;
|
||||
t.uniforms.uRippleSpeed.value = rippleSpeed;
|
||||
t.uniforms.uEdgeFade.value = edgeFade;
|
||||
|
||||
if (transparent) t.renderer.setClearAlpha(0);
|
||||
else t.renderer.setClearColor(0x000000, 1);
|
||||
|
||||
if (t.liquidEffect) {
|
||||
const uStrength = t.liquidEffect.uniforms.get('uStrength');
|
||||
if (uStrength) uStrength.value = liquidStrength;
|
||||
const uFreq = t.liquidEffect.uniforms.get('uFreq');
|
||||
if (uFreq) uFreq.value = liquidWobbleSpeed;
|
||||
}
|
||||
|
||||
if (t.touch) t.touch.radiusScale = liquidRadius;
|
||||
}
|
||||
|
||||
prevConfigRef.current = cfg;
|
||||
|
||||
return () => {
|
||||
if (threeRef.current && mustReinit) return;
|
||||
if (!threeRef.current) return;
|
||||
|
||||
const t = threeRef.current;
|
||||
t.resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(t.raf);
|
||||
t.quad?.geometry.dispose();
|
||||
t.material.dispose();
|
||||
t.composer?.dispose();
|
||||
t.renderer.dispose();
|
||||
if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
|
||||
threeRef.current = null;
|
||||
};
|
||||
}, [
|
||||
antialias,
|
||||
liquid,
|
||||
noiseAmount,
|
||||
pixelSize,
|
||||
patternScale,
|
||||
patternDensity,
|
||||
enableRipples,
|
||||
rippleIntensityScale,
|
||||
rippleThickness,
|
||||
rippleSpeed,
|
||||
pixelSizeJitter,
|
||||
edgeFade,
|
||||
transparent,
|
||||
liquidStrength,
|
||||
liquidRadius,
|
||||
liquidWobbleSpeed,
|
||||
autoPauseOffscreen,
|
||||
variant,
|
||||
color,
|
||||
speed
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pixel-blast-container ${className ?? ''}`}
|
||||
style={style}
|
||||
aria-label="PixelBlast interactive background"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PixelBlast;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
const RESPONSE_LEVELS = [
|
||||
{ value: 'KEY USER', label: 'Key User' },
|
||||
{ value: 'Helpdesk', label: '1st Level' },
|
||||
{ value: 'Support', label: '2nd Level' },
|
||||
{ value: 'Admin', label: '3rd Level' },
|
||||
{ value: 'FS/FE', label: 'FS/FE' },
|
||||
{ value: '24/7', label: '24/7' },
|
||||
{ value: 'TECH MGMT', label: 'Tech Mgmt' },
|
||||
{ value: 'Backoffice', label: 'Backoffice' },
|
||||
{ value: 'BUSI MGMT', label: 'Busi Mgmt' }
|
||||
]
|
||||
|
||||
export default function ResponseDropdown({ value, onChange }) {
|
||||
return (
|
||||
<div className="dropdown">
|
||||
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
|
||||
{value?.toUpperCase() || 'BACKOFFICE'}
|
||||
</button>
|
||||
<div className="dropdown-content">
|
||||
{RESPONSE_LEVELS.map(level => (
|
||||
<span
|
||||
key={level.value}
|
||||
className="dropdown-item"
|
||||
onClick={() => onChange(level.value)}
|
||||
>
|
||||
{level.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { FaCaretDown } from 'react-icons/fa6'
|
||||
|
||||
const TICKET_TYPES = [
|
||||
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
|
||||
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
|
||||
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
|
||||
'Rollout', 'Emergency Call', 'Other Services'
|
||||
]
|
||||
|
||||
const SYSTEMS = [
|
||||
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
|
||||
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
|
||||
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
|
||||
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
|
||||
]
|
||||
|
||||
const STATUSES = [
|
||||
'Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info',
|
||||
'In Test', 'Halted', 'Aborted', 'Cancelled', 'Closed'
|
||||
]
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 1, label: 'Low' },
|
||||
{ value: 2, label: 'Medium' },
|
||||
{ value: 3, label: 'High' },
|
||||
{ value: 4, label: 'Critical' }
|
||||
]
|
||||
|
||||
export default function TicketFilters({ filters, onChange, onApply }) {
|
||||
const [localFilters, setLocalFilters] = useState(filters)
|
||||
|
||||
const handleCheckboxChange = (category, value) => {
|
||||
const current = localFilters[category] || []
|
||||
const updated = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value]
|
||||
setLocalFilters({ ...localFilters, [category]: updated })
|
||||
}
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setLocalFilters({ ...localFilters, [field]: value })
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
onChange(localFilters)
|
||||
onApply()
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="bg-dark-grey text-white">
|
||||
<td className="text-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="WOID"
|
||||
className="form-control"
|
||||
style={{ width: '80px', background: '#616161', color: '#fff', border: 'none' }}
|
||||
value={localFilters.woid || ''}
|
||||
onChange={(e) => handleInputChange('woid', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Created"
|
||||
className="form-control"
|
||||
style={{ width: '100px', background: '#616161', color: '#fff', border: 'none' }}
|
||||
value={localFilters.createdDate || ''}
|
||||
onChange={(e) => handleInputChange('createdDate', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-dark">
|
||||
Type / Location <FaCaretDown />
|
||||
</button>
|
||||
<div className="dropdown-content" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{TICKET_TYPES.map(type => (
|
||||
<label key={type} className="dropdown-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(localFilters.type || []).includes(type)}
|
||||
onChange={() => handleCheckboxChange('type', type)}
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-dark">
|
||||
System <FaCaretDown />
|
||||
</button>
|
||||
<div className="dropdown-content" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{SYSTEMS.map(system => (
|
||||
<label key={system} className="dropdown-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(localFilters.system || []).includes(system)}
|
||||
onChange={() => handleCheckboxChange('system', system)}
|
||||
/>
|
||||
{system}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Customer or Location"
|
||||
className="form-control"
|
||||
style={{ width: '150px', background: '#616161', color: '#fff', border: 'none' }}
|
||||
value={localFilters.customer || ''}
|
||||
onChange={(e) => handleInputChange('customer', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="User or Topic"
|
||||
className="form-control"
|
||||
style={{ width: '150px', background: '#616161', color: '#fff', border: 'none' }}
|
||||
value={localFilters.userTopic || ''}
|
||||
onChange={(e) => handleInputChange('userTopic', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-dark">
|
||||
Status <FaCaretDown />
|
||||
</button>
|
||||
<div className="dropdown-content">
|
||||
{STATUSES.map(status => (
|
||||
<label key={status} className="dropdown-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(localFilters.status || []).includes(status)}
|
||||
onChange={() => handleCheckboxChange('status', status)}
|
||||
/>
|
||||
{status}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="dropdown">
|
||||
<button className="btn btn-dark">
|
||||
Priority <FaCaretDown />
|
||||
</button>
|
||||
<div className="dropdown-content">
|
||||
{PRIORITIES.map(p => (
|
||||
<label key={p.value} className="dropdown-item" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(localFilters.priority || []).includes(p.value)}
|
||||
onChange={() => handleCheckboxChange('priority', p.value)}
|
||||
/>
|
||||
{p.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-center">Approval</td>
|
||||
<td className="text-center">
|
||||
<button className="btn btn-green" onClick={handleApply}>
|
||||
Apply!
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft, FaPen } from 'react-icons/fa6'
|
||||
import { formatDistanceToNow, format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import StatusDropdown from './StatusDropdown'
|
||||
import PriorityDropdown from './PriorityDropdown'
|
||||
import EditorDropdown from './EditorDropdown'
|
||||
import ResponseDropdown from './ResponseDropdown'
|
||||
import CreateWorksheetModal from './CreateWorksheetModal'
|
||||
import EditWorksheetTimeModal from './EditWorksheetTimeModal'
|
||||
import StatusHistoryModal from './StatusHistoryModal'
|
||||
import WorksheetList from './WorksheetList'
|
||||
import WorksheetStats from './WorksheetStats'
|
||||
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||
import TicketProjectsModal from './TicketProjectsModal'
|
||||
import InvoicePanel from './InvoicePanel'
|
||||
import DocumentsPanel from './DocumentsPanel'
|
||||
import { useWorksheets } from '../hooks/useWorksheets'
|
||||
|
||||
const PRIORITY_CLASSES = {
|
||||
0: 'priority-none',
|
||||
1: 'priority-low',
|
||||
2: 'priority-medium',
|
||||
3: 'priority-high',
|
||||
4: 'priority-critical'
|
||||
}
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
'Open': 'status-open',
|
||||
'Occupied': 'status-occupied',
|
||||
'Assigned': 'status-assigned',
|
||||
'Awaiting': 'status-awaiting',
|
||||
'Closed': 'status-closed'
|
||||
}
|
||||
|
||||
const APPROVAL_ICONS = {
|
||||
'pending': FaUserGear,
|
||||
'approved': FaSackDollar,
|
||||
'quote': FaSackDollar,
|
||||
'shipping': FaTruck
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'worksheets', label: 'Arbeitsblaetter' },
|
||||
{ id: 'invoices', label: 'Rechnungen' },
|
||||
{ id: 'documents', label: 'Dokumente' },
|
||||
{ id: 'project', label: 'Projekt / Preview' },
|
||||
]
|
||||
|
||||
export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [locked, setLocked] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||
const [editTimeWorksheet, setEditTimeWorksheet] = useState(null)
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||
|
||||
const {
|
||||
worksheets,
|
||||
loading: worksheetsLoading,
|
||||
createWorksheet,
|
||||
updateWorksheet,
|
||||
getTotalTime
|
||||
} = useWorksheets(expanded ? ticket.woid : null)
|
||||
|
||||
// Startzeit fuer Git-Push-Worksheets nachtragen (Endzeit kam vom Push)
|
||||
const handleSaveWorksheetTime = async (ws, data) => updateWorksheet(ws.$id, data)
|
||||
// Wartungs-Abo: Arbeitszeit aufs Stundenkonto des Kunden buchen (Kundenportal zeigt den Stand)
|
||||
const handleTogglePlan = async (ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })
|
||||
|
||||
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
||||
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
||||
|
||||
const handleStatusChange = (newStatus) => onUpdate(ticket.$id, { status: newStatus })
|
||||
const handlePriorityChange = (newPriority) => onUpdate(ticket.$id, { priority: newPriority })
|
||||
const handleEditorChange = (newEditor) => onUpdate(ticket.$id, { assignedTo: newEditor })
|
||||
const handleResponseChange = (newResponse) => onUpdate(ticket.$id, { responseLevel: newResponse })
|
||||
|
||||
const toggleLock = () => {
|
||||
if (locked) {
|
||||
setExpanded(true)
|
||||
onExpand?.(ticket.$id)
|
||||
} else {
|
||||
setExpanded(false)
|
||||
}
|
||||
setLocked(!locked)
|
||||
}
|
||||
|
||||
const handleCreateWorksheet = async (worksheetData, currentUser) => {
|
||||
const result = await createWorksheet(worksheetData, currentUser)
|
||||
if (result.success && worksheetData.newStatus !== ticket.status) {
|
||||
await onUpdate(ticket.$id, {
|
||||
status: worksheetData.newStatus,
|
||||
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const ApprovalIcon = APPROVAL_ICONS[ticket.approvalStatus] || FaUserGear
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={`ticket-row ${expanded ? 'ticket-expanded' : 'ticket-collapsed'}`}>
|
||||
<td className={`ticket-id ${expanded ? 'ticket-id-expanded' : ''}`} rowSpan={2}>
|
||||
<div><strong>WOID:</strong> {ticket.woid || ticket.$id?.slice(-5)}</div>
|
||||
<div className="ticket-time">{elapsed}</div>
|
||||
</td>
|
||||
<td className="ticket-info" rowSpan={2}>
|
||||
<strong>{format(createdAt, 'dd.MM.yyyy')}</strong>
|
||||
</td>
|
||||
<td className="ticket-info" rowSpan={2}>
|
||||
<strong>{ticket.type}</strong>
|
||||
<br />
|
||||
{ticket.serviceType || 'Remote'}
|
||||
<br />
|
||||
<FaPlay className="text-green" /> {ticket.startDate || format(createdAt, 'dd.MM.yyyy')}
|
||||
<br />
|
||||
<FaStop className="text-red" /> {ticket.deadline || '-'}
|
||||
</td>
|
||||
<td className="ticket-info" rowSpan={2}>
|
||||
<strong>{ticket.systemType || 'n/a'}</strong>
|
||||
</td>
|
||||
<td rowSpan={2}>
|
||||
<strong>{ticket.customerName || 'Unknown'}</strong>
|
||||
<br />
|
||||
{ticket.customerLocation || ''}
|
||||
</td>
|
||||
<td rowSpan={2}>
|
||||
<strong>{ticket.requestedBy || '-'}</strong>
|
||||
<br />
|
||||
{ticket.requestedFor && <>Requested for: {ticket.requestedFor}<br /></>}
|
||||
{ticket.topic}
|
||||
</td>
|
||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||
<StatusDropdown value={ticket.status} onChange={handleStatusChange} />
|
||||
</td>
|
||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||
<PriorityDropdown value={ticket.priority} onChange={handlePriorityChange} />
|
||||
</td>
|
||||
<td
|
||||
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
||||
rowSpan={2}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
>
|
||||
<ApprovalIcon size={24} />
|
||||
</td>
|
||||
<td
|
||||
className="bg-dark-grey text-center text-white"
|
||||
rowSpan={2}
|
||||
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
|
||||
onClick={toggleLock}
|
||||
>
|
||||
{locked ? <FaLock size={24} /> : <FaLockOpen size={24} />}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="ticket-row">
|
||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||
<EditorDropdown value={ticket.assignedTo} onChange={handleEditorChange} />
|
||||
</td>
|
||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||
<ResponseDropdown value={ticket.responseLevel} onChange={handleResponseChange} />
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="worksheet-expansion">
|
||||
<td colSpan={10} className="worksheet-cell">
|
||||
<div className="card" style={{
|
||||
borderRadius: '0 0 12px 12px',
|
||||
marginTop: 0,
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderTop: 'none'
|
||||
}}>
|
||||
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
||||
|
||||
{/* Tab-Leiste */}
|
||||
<div className="ticket-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`ticket-tab ${activeTab === t.id ? 'ticket-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ticket-tab-content">
|
||||
{activeTab === 'details' && (
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.5)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
}}>
|
||||
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', marginBottom: '16px', fontSize: '18px' }}>
|
||||
Ticket-Beschreibung
|
||||
</h5>
|
||||
<p style={{ fontSize: '14px', lineHeight: '1.8', color: 'rgba(226, 232, 240, 0.85)', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
{ticket.details || 'Keine Details vorhanden.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'worksheets' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white', border: 'none', padding: '12px 20px', borderRadius: '8px',
|
||||
fontWeight: 'bold', cursor: 'pointer', flex: 1
|
||||
}}
|
||||
onClick={() => setShowCreateWorksheet(true)}
|
||||
>
|
||||
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||
onClick={() => setShowProjectsModal(true)}
|
||||
title="Projekte bearbeiten"
|
||||
>
|
||||
<FaPen size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ background: '#616161', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||
onClick={() => setShowHistoryModal(true)}
|
||||
title="Status History"
|
||||
>
|
||||
<FaClockRotateLeft size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{worksheets.length > 0 && (
|
||||
<div style={{ background: 'rgba(45, 55, 72, 0.5)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(16, 185, 129, 0.2)', marginBottom: '16px' }}>
|
||||
<WorksheetStats worksheets={worksheets} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} onEditTime={setEditTimeWorksheet} onTogglePlan={handleTogglePlan} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||
|
||||
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||
|
||||
{activeTab === 'project' && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-green"
|
||||
style={{ marginBottom: '12px' }}
|
||||
onClick={() => setShowProjectsModal(true)}
|
||||
>
|
||||
<FaPen style={{ marginRight: 6 }} /> Projekte zuweisen / bearbeiten
|
||||
</button>
|
||||
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<CreateWorksheetModal
|
||||
isOpen={showCreateWorksheet}
|
||||
onClose={() => setShowCreateWorksheet(false)}
|
||||
workorder={ticket}
|
||||
onCreate={handleCreateWorksheet}
|
||||
/>
|
||||
|
||||
<EditWorksheetTimeModal
|
||||
isOpen={Boolean(editTimeWorksheet)}
|
||||
onClose={() => setEditTimeWorksheet(null)}
|
||||
worksheet={editTimeWorksheet}
|
||||
onSave={handleSaveWorksheetTime}
|
||||
/>
|
||||
|
||||
<StatusHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => setShowHistoryModal(false)}
|
||||
worksheets={worksheets}
|
||||
ticket={ticket}
|
||||
/>
|
||||
|
||||
<TicketProjectsModal
|
||||
isOpen={showProjectsModal}
|
||||
onClose={() => setShowProjectsModal(false)}
|
||||
ticket={ticket}
|
||||
onUpdated={() => setProjectsRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
<tr className="spacer">
|
||||
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
|
||||
</tr>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FaPlus, FaTrash } from 'react-icons/fa'
|
||||
import PreviewLinkButton from './PreviewLinkButton'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { createProjectFromTemplate } from '../lib/projectAdminApi'
|
||||
import { WEBPAGE_TICKET_TYPE } from '../lib/appwrite'
|
||||
|
||||
function slugify(v) {
|
||||
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
|
||||
}
|
||||
|
||||
export default function WebpageProjectPanel({ ticket }) {
|
||||
const { fetchAllProjects, fetchByTicketId, getAvailableProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
||||
const [assigned, setAssigned] = useState([])
|
||||
const [available, setAvailable] = useState([])
|
||||
const [selectedIds, setSelectedIds] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '', repoName: '' })
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
if (!ticket?.$id || ticket.type !== WEBPAGE_TICKET_TYPE) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
|
||||
setAssigned(mine)
|
||||
const ids = new Set(mine.map((p) => p.$id))
|
||||
setAvailable(getAvailableProjects(all, ticket.customerId).filter((p) => !ids.has(p.$id)))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [ticket, fetchAllProjects, fetchByTicketId, getAvailableProjects])
|
||||
|
||||
useEffect(() => { loadProjects() }, [loadProjects])
|
||||
if (ticket?.type !== WEBPAGE_TICKET_TYPE) return null
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedIds.length || !ticket.customerId) return
|
||||
setActionLoading(true)
|
||||
const r = await assignProjects(selectedIds, { customerId: ticket.customerId, ticketId: ticket.$id })
|
||||
setActionLoading(false)
|
||||
if (r.success) { setSelectedIds([]); await loadProjects() } else setError(r.error)
|
||||
}
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!ticket.customerId) { setError('Kein Kunde am Ticket.'); return }
|
||||
setActionLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await createProjectFromTemplate({
|
||||
repoName: createForm.repoName || createForm.subdomain,
|
||||
displayName: createForm.displayName,
|
||||
subdomain: slugify(createForm.subdomain),
|
||||
customerId: ticket.customerId,
|
||||
ticketId: ticket.$id,
|
||||
})
|
||||
setCreateForm({ displayName: '', subdomain: '', repoName: '' })
|
||||
await loadProjects()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'rgba(45,55,72,0.5)', borderRadius: 12, padding: 20, border: '1px solid rgba(59,130,246,0.3)', marginTop: 20 }}>
|
||||
<h5 style={{ fontWeight: 'bold', marginBottom: 12 }}>Website-Projekte</h5>
|
||||
{error && <div className="bg-red text-white p-2 mb-2">{error}</div>}
|
||||
{loading ? <p className="text-grey">Laden...</p> : (
|
||||
<>
|
||||
<section style={{ marginBottom: 16 }}>
|
||||
<h6>Zugewiesen</h6>
|
||||
{assigned.length === 0 ? <p className="text-grey">Keine Projekte.</p> : assigned.map((p) => (
|
||||
<div key={p.$id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
|
||||
<span>{p.projectName} ({p.subdomain})</span>
|
||||
<PreviewLinkButton href={p.previewUrl} />
|
||||
<button type="button" className="btn btn-sm" onClick={() => unassignProject(p.$id).then(loadProjects)}><FaTrash /></button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section style={{ marginBottom: 16 }}>
|
||||
<h6>Verf�gbar zuweisen</h6>
|
||||
{available.map((p) => (
|
||||
<label key={p.$id} style={{ display: 'block' }}>
|
||||
<input type="checkbox" checked={selectedIds.includes(p.$id)} onChange={() => setSelectedIds((s) => s.includes(p.$id) ? s.filter((x) => x !== p.$id) : [...s, p.$id])} />
|
||||
{' '}{p.projectName} ({p.subdomain})
|
||||
</label>
|
||||
))}
|
||||
<button type="button" className="btn btn-teal mt-2" disabled={!selectedIds.length || actionLoading} onClick={handleAssign}>Zuweisen</button>
|
||||
</section>
|
||||
<section>
|
||||
<h6><FaPlus /> Neues Projekt</h6>
|
||||
<form onSubmit={handleCreate} style={{ display: 'grid', gap: 8, maxWidth: 400 }}>
|
||||
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
|
||||
<input className="form-control" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value, repoName: f.repoName || slugify(e.target.value) }))} required />
|
||||
<button type="submit" className="btn btn-dark" disabled={actionLoading}>Anlegen & zuweisen</button>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const headerButtonStyle = {
|
||||
marginRight: '8px',
|
||||
}
|
||||
|
||||
export default function WorksheetList({ worksheets, totalTime, loading, onEditTime, onTogglePlan, canEditTimes, onEditTimes }) {
|
||||
export default function WorksheetList({ worksheets, totalTime, loading, onTogglePlan, canEditTimes, onEditTimes }) {
|
||||
const [expandedWorksheets, setExpandedWorksheets] = useState({})
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -162,19 +162,6 @@ export default function WorksheetList({ worksheets, totalTime, loading, onEditTi
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{needsTimeEntry(ws) && onEditTime && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={headerButtonStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditTime(ws)
|
||||
}}
|
||||
>
|
||||
<FaClock className="me-1" /> Zeit nachtragen
|
||||
</button>
|
||||
)}
|
||||
{onEditTimes && canEditTimes?.(ws) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in new issue
Block a user