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:
ClaudeandClaude Opus 5.5 committed 2026-09-27 14:33:37 +00:00
1 parent 8d2a99e5d6
commit d684aea576
15 files changed
+3 -1999

No files matched your search

-142
View File
@@ -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>
)
}
-138
View File
@@ -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);
}
}
-116
View File
@@ -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>
)
}
-7
View File
@@ -1,7 +0,0 @@
.pixel-blast-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
-674
View File
@@ -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;
-32
View File
@@ -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>
)
}
-177
View File
@@ -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>
)
}
-308
View File
@@ -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>
</>
)
}
-109
View File
@@ -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>
)
}
+1 -14
View File
@@ -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"