feat(audit, frontend, backend): introduce audit scripts and enhance error handling

- Added new npm scripts for auditing frontend size and inline TODOs, improving code quality management.
- Enhanced error handling in the `nuscoreApiRoutes` to return specific validation error statuses, improving API response clarity.
- Updated SQL migration documentation to establish a clear process for manual migrations and ensure backward compatibility.
- Refactored various components to align with new design standards, enhancing UI consistency across the application.
This commit is contained in:
Torsten Schulz (local)
2026-03-18 11:23:03 +01:00
parent b7b40f5a9b
commit 79adad9564
16 changed files with 866 additions and 325 deletions

View File

@@ -188,6 +188,18 @@ router.put('/submit/:uuid', async (req, res) => {
const { uuid } = req.params;
const reportData = req.body;
console.log('[nuscore submit] request', {
uuid,
wo: reportData?.wo ?? null,
isCompleted: reportData?.isCompleted ?? null,
homePin: reportData?.homePin ?? null,
guestPin: reportData?.guestPin ?? null,
releaseSignatureHome: reportData?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: reportData?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: reportData?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: reportData?.signature?.lineupSignatureGuest ?? null
});
try {
// Hole Cookies für diese UUID (falls vorhanden)
// Versuche zuerst UUID, dann Code als Fallback
@@ -229,12 +241,26 @@ router.put('/submit/:uuid', async (req, res) => {
responseData = { message: responseText };
}
if (!response.ok) {
console.log('[nuscore submit] response', {
uuid,
httpStatus: response.status,
resultState: responseData?.resultState ?? null,
validationErrors: responseData?.validationErrors ?? [],
releaseSignatureHome: responseData?.object?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: responseData?.object?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: responseData?.object?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: responseData?.object?.signature?.lineupSignatureGuest ?? null
});
const resultState = responseData?.resultState;
const validationErrors = Array.isArray(responseData?.validationErrors) ? responseData.validationErrors : [];
if (!response.ok || resultState === 'VALIDATION_ERROR') {
console.error(`❌ Submit fehlgeschlagen: HTTP ${response.status}`, responseData);
return res.status(response.status).json({
return res.status(resultState === 'VALIDATION_ERROR' ? 422 : response.status).json({
error: 'Fehler beim Absenden des Spielberichts',
details: responseData,
status: response.status,
status: resultState === 'VALIDATION_ERROR' ? 422 : response.status,
statusText: response.statusText
});
}
@@ -294,6 +320,18 @@ router.put('/validate/:uuid', async (req, res) => {
const { uuid } = req.params;
const reportData = req.body;
console.log('[nuscore validate] request', {
uuid,
wo: reportData?.wo ?? null,
isCompleted: reportData?.isCompleted ?? null,
homePin: reportData?.homePin ?? null,
guestPin: reportData?.guestPin ?? null,
releaseSignatureHome: reportData?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: reportData?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: reportData?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: reportData?.signature?.lineupSignatureGuest ?? null
});
try {
// Hole Cookies für diese UUID (falls vorhanden)
// Versuche zuerst UUID, dann Code als Fallback
@@ -335,6 +373,17 @@ router.put('/validate/:uuid', async (req, res) => {
responseData = { message: responseText };
}
console.log('[nuscore validate] response', {
uuid,
httpStatus: response.status,
resultState: responseData?.resultState ?? null,
validationErrors: responseData?.validationErrors ?? [],
releaseSignatureHome: responseData?.object?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: responseData?.object?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: responseData?.object?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: responseData?.object?.signature?.lineupSignatureGuest ?? null
});
// Speichere neue Cookies falls vorhanden
const newCookies = extractCookies(response.headers.raw()['set-cookie']);
if (Object.keys(newCookies).length > 0) {

View File

@@ -102,7 +102,7 @@ Diese Liste beschreibt die naechsten sinnvollen Optimierungsschritte nach dem zu
## Prioritaet D
- [ ] Selten genutzte Spezialviews optisch komplett an das Theme angleichen.
- [x] Selten genutzte Spezialviews optisch komplett an das Theme angleichen.
Wahrscheinliche Kandidaten:
- `MatchReportDialog.vue`
- `NuscoreAnalyzer.vue`
@@ -110,25 +110,42 @@ Diese Liste beschreibt die naechsten sinnvollen Optimierungsschritte nach dem zu
- `PermissionsView.vue`
Ziel:
- keine hart sichtbaren Bootstrap-/Altfarben in Randbereichen
Erledigt am 2026-03-17:
- `MatchReportDialog.vue`, `NuscoreAnalyzer.vue` und `PermissionsView.vue` auf Theme-Farben und ruhigere Statusflaechen umgestellt
- `TournamentResultsTab.vue` als verbleibender sichtbarer Tournament-Randbereich an die Badge-/Statuslogik des Themes angepasst
- [ ] Zeichnungs-/Court-Komponenten visuell und technisch aufraeumen.
- [x] Zeichnungs-/Court-Komponenten visuell und technisch aufraeumen.
Kandidaten:
- `CourtDrawingTool.vue`
- `CourtDrawingRender.vue`
Grund:
- grosse Komponenten
- funktionale Farbsemantik gemischt mit Alt-Styling
Erledigt am 2026-03-17:
- Court-Paletten zentralisiert
- Tool-/Render-Flaechen, Buttons und Grid-Zustaende ans Theme angepasst
- verbliebene harte Altfarben im sichtbaren UI deutlich reduziert
## Querliegende Verbesserungen
- [ ] Manuelle SQL-Migrationen kuenftig direkt mit jeder Schemaaenderung in `docs/manual_sql_migrations.md` mitpflegen.
- [x] Manuelle SQL-Migrationen kuenftig direkt mit jeder Schemaaenderung in `docs/manual_sql_migrations.md` mitpflegen.
Erledigt am 2026-03-18:
- Pflege-Regel direkt in `docs/manual_sql_migrations.md` dokumentiert
- SQL-Migrationsdoku damit nicht mehr nur Sammelablage, sondern verbindlicher Prozesspunkt
- [ ] Fuer grosse Frontend-Dateien eine grobe Zielgroesse einfuehren.
- [x] Fuer grosse Frontend-Dateien eine grobe Zielgroesse einfuehren.
Vorschlag:
- Warnbereich ab ~80 KB
- struktureller Refactor ab ~120 KB
Erledigt am 2026-03-18:
- `docs/frontend_size_budget.md` angelegt
- Audit-Skript `npm run audit:frontend-size` ergaenzt
- [ ] Inline-TODOs/FIXMEs regelmaessig in Repo-Doku ueberfuehren und aus produktiven Hauptdateien entfernen.
- [x] Inline-TODOs/FIXMEs regelmaessig in Repo-Doku ueberfuehren und aus produktiven Hauptdateien entfernen.
Erledigt am 2026-03-18:
- `docs/inline_todo_registry.md` angelegt
- Audit-Skript `npm run audit:inline-todos` ergaenzt
- Produktivcode per Audit geprueft: keine offenen Marker in `frontend/src` und `backend`
## Aktuelle groesste Kandidaten nach Dateigroesse

View File

@@ -0,0 +1,44 @@
# Frontend Size Budget
Stand: 2026-03-18
## Ziel
Grosse Frontend-Dateien sollen frueh sichtbar werden, bevor einzelne Views oder Komponenten wieder schwer wartbar werden.
## Regeln
- `warn` ab grob `80 KB`
- `refactor` ab grob `120 KB`
Diese Werte sind bewusst pragmatisch und gelten fuer:
- `.vue`
- `.js`
- `.ts`
- `.scss`
unter `frontend/src/`.
## Verwendung
Audit aus dem Repo-Root:
```bash
npm run audit:frontend-size
```
Das Skript liefert eine sortierte Liste der groessten Frontend-Dateien und markiert sie als:
- `ok`
- `warn`
- `refactor`
## Aktueller Umgang
- `warn` bedeutet: bei der naechsten groesseren Aenderung Struktur pruefen.
- `refactor` bedeutet: keine weitere groessere Fachlogik ohne vorherige oder parallele Extraktion in Subkomponenten/Services.
## Aktuelle Kandidaten
Die aktuelle Liste wird bewusst nicht statisch doppelt gepflegt. Stattdessen ist `npm run audit:frontend-size` die Referenz.

View File

@@ -0,0 +1,30 @@
# Inline TODO Registry
Stand: 2026-03-18
## Regel
Produktiver Frontend-/Backend-Code soll keine offenen `TODO`, `FIXME` oder `XXX` Marker dauerhaft enthalten.
Wenn waehrend der Entwicklung ein solcher Marker noetig ist:
1. so kurz wie moeglich im Code belassen
2. in eine Doku-Datei oder Issue-Liste ueberfuehren
3. den Marker im Produktivcode wieder entfernen
## Audit
Aus dem Repo-Root:
```bash
npm run audit:inline-todos
```
## Aktueller Stand
Zum Stand `2026-03-18` gibt es keine offenen `TODO`-/`FIXME`-/`XXX`-Marker mehr in:
- `frontend/src`
- `backend`
Verbleibende TODO-Abschnitte liegen nur noch in Dokumentation, z.B. fuer Android-Portierung oder fachliche Backlog-Punkte.

View File

@@ -2,6 +2,20 @@
Diese Datei sammelt SQL-Aenderungen, die auf Live/Test manuell eingespielt werden muessen, wenn das Produktions-Setup keine automatische Schema-Migration ausfuehrt.
## Pflege-Regel
Ab jetzt gilt fuer jede Schemaaenderung:
1. SQL fuer `test` und `live` direkt hier eintragen.
2. Datum, betroffene Tabelle/Felder und optionalen Backfill dokumentieren.
3. Erst danach den Frontend-/Backend-Teil als abgeschlossen markieren.
Ergaenzend:
- Die produktive Anwendung soll sich nicht auf `sync({ alter: true })` verlassen.
- Manuelle Migrationsschritte gehoeren in diese Datei, nicht nur in Chat-Verlaeufe oder Commit-Messages.
- Wenn eine Aenderung rueckwaertskompatibel ist, soll das hier explizit vermerkt werden.
## 2026-03-17
### `predefined_activities.exclude_from_stats`
@@ -49,3 +63,7 @@ Optionaler Schutz gegen doppelte Teilnehmerzeilen pro Trainingstag:
ALTER TABLE participants
ADD UNIQUE KEY participants_diary_date_member_unique (diary_date_id, member_id);
```
Rueckwaertskompatibilitaet:
- Bestehende Datensaetze bleiben durch den Default `present` gueltig.

View File

@@ -14,13 +14,33 @@
ref="canvas"
:width="config.canvas.width"
:height="config.canvas.height"
style="margin:0 auto;"
class="render-canvas"
></canvas>
</div>
</template>
<script>
const COURT_RENDER_PALETTE = {
table: '#315f43',
frame: '#ffffff',
frameBorder: '#102418',
line: '#d9e4dd',
net: '#ffffff',
netBorder: '#102418',
startSelected: '#c84c32',
startMuted: '#8d948f',
startMutedBorder: '#4d5650',
arrowPrimary: '#c84c32',
arrowSecondary: '#1f6a8a',
arrowTertiary: '#4f7f32',
arrowQuaternary: '#8a5a1f',
markerFill: '#ffffff',
markerStroke: '#102418',
surface: '#eef3ef',
buttonDisabled: '#7b847e'
};
export default {
name: 'CourtDrawingRender',
props: {
@@ -51,16 +71,16 @@ export default {
// Tabelle etwas schmaler und weiter rechts platzieren
width: 500,
height: 300,
color: '#2d5a2d',
color: COURT_RENDER_PALETTE.table,
borderWidth: 2,
borderColor: '#000000',
borderColor: COURT_RENDER_PALETTE.frameBorder,
outerFrameWidth: 5,
outerFrameColor: '#ffffff',
outerFrameColor: COURT_RENDER_PALETTE.frame,
outerFrameBorderWidth: 0.5,
outerFrameBorderColor: '#000000'
outerFrameBorderColor: COURT_RENDER_PALETTE.frameBorder
},
horizontalLine: { color: '#cfd8dc', width: 2, gap: 10, edgeMargin: 10 },
net: { color: '#ffffff', width: 4, overhang: 14, borderColor: '#000000', borderWidth: 0.5 },
horizontalLine: { color: COURT_RENDER_PALETTE.line, width: 2, gap: 10, edgeMargin: 10 },
net: { color: COURT_RENDER_PALETTE.net, width: 4, overhang: 14, borderColor: COURT_RENDER_PALETTE.netBorder, borderWidth: 0.5 },
serviceLine: { color: 'rgba(255,255,255,0.0)', width: 0 },
startCircles: {
radius: 10,
@@ -68,18 +88,18 @@ export default {
x: 20,
topYOffset: 35,
bottomYOffset: 35,
selectedColor: '#ff4444',
unselectedColor: '#9e9e9e',
selectedBorderColor: '#ffffff',
unselectedBorderColor: '#555555',
selectedColor: COURT_RENDER_PALETTE.startSelected,
unselectedColor: COURT_RENDER_PALETTE.startMuted,
selectedBorderColor: COURT_RENDER_PALETTE.frame,
unselectedBorderColor: COURT_RENDER_PALETTE.startMutedBorder,
selectedBorderWidth: 2,
unselectedBorderWidth: 1
},
arrows: {
primaryColor: '#d32f2f', // rechts -> target (rot)
secondaryColor: '#1565c0', // zurück (blau)
tertiaryColor: '#2e7d32', // dritter Schlag (grün)
quaternaryColor: '#6a1b9a', // vierter Schlag (violett)
primaryColor: COURT_RENDER_PALETTE.arrowPrimary,
secondaryColor: COURT_RENDER_PALETTE.arrowSecondary,
tertiaryColor: COURT_RENDER_PALETTE.arrowTertiary,
quaternaryColor: COURT_RENDER_PALETTE.arrowQuaternary,
width: 6,
headLength: 24,
vhOffsetX: 5,
@@ -104,8 +124,8 @@ export default {
},
hitMarker: {
radius: 10.5,
fill: '#ffffff',
stroke: '#000000',
fill: COURT_RENDER_PALETTE.markerFill,
stroke: COURT_RENDER_PALETTE.markerStroke,
lineWidth: 1
}
}
@@ -159,7 +179,7 @@ export default {
const { width, height } = this.config.canvas;
// clear and background
this.ctx.clearRect(0, 0, width, height);
this.ctx.fillStyle = '#f0f0f0';
this.ctx.fillStyle = COURT_RENDER_PALETTE.surface;
this.ctx.fillRect(0, 0, width, height);
this.drawTable();
this.drawStartCircles();
@@ -350,8 +370,8 @@ export default {
// clamp innerhalb Canvas
x = Math.max(4, Math.min(this.config.canvas.width - textWidth - 4, x));
y = Math.max(14, Math.min(this.config.canvas.height - 4, y));
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.fillStyle = COURT_RENDER_PALETTE.markerFill;
ctx.strokeStyle = COURT_RENDER_PALETTE.markerStroke;
ctx.lineWidth = 2;
ctx.strokeText(text, x, y);
ctx.fillText(text, x, y);
@@ -681,6 +701,10 @@ canvas {
height: auto;
}
.render-canvas {
margin: 0 auto;
}
.animation-controls {
text-align: center;
margin-bottom: 1rem;
@@ -706,8 +730,7 @@ canvas {
.btn-animate:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #6c757d;
border-color: #6c757d;
background-color: #7b847e;
border-color: #7b847e;
}
</style>

View File

@@ -217,8 +217,7 @@
</div>
<button
type="button"
class="btn-primary btn-small"
style="vertical-align:bottom"
class="btn-primary btn-small add-stroke-button"
@click="addNextStroke"
:disabled="additionalStrokes.length >= 4"
>
@@ -239,6 +238,25 @@
</template>
<script>
const COURT_TOOL_PALETTE = {
table: '#315f43',
frame: '#ffffff',
frameBorder: '#102418',
line: '#ffffff',
startSelected: '#c84c32',
startMuted: '#8d948f',
targetSelected: '#6f8f3b',
targetBorder: '#102418',
targetText: '#102418',
arrowPrimary: '#c84c32',
arrowSupport: '#1f6a8a',
arrowAccent: '#8a5a1f',
arrowHighlight: '#c7a42a',
arrowAlternate: '#7a4ea3',
canvasSurface: '#eef3ef',
accentText: '#f1d26b'
};
export default {
name: 'CourtDrawingTool',
props: {
@@ -270,40 +288,40 @@ export default {
table: {
width: 500,
height: 300,
color: '#2d5a2d',
color: COURT_TOOL_PALETTE.table,
borderWidth: 2,
borderColor: '#000000',
borderColor: COURT_TOOL_PALETTE.frameBorder,
outerFrameWidth: 5,
outerFrameColor: '#ffffff',
outerFrameColor: COURT_TOOL_PALETTE.frame,
outerFrameBorderWidth: 0.5,
outerFrameBorderColor: '#000000'
outerFrameBorderColor: COURT_TOOL_PALETTE.frameBorder
},
net: {
width: 4,
color: '#ffffff',
color: COURT_TOOL_PALETTE.frame,
borderWidth: 1,
borderColor: '#000000',
borderColor: COURT_TOOL_PALETTE.frameBorder,
overhang: 15
},
horizontalLine: {
width: 1,
color: '#ffffff',
color: COURT_TOOL_PALETTE.line,
gap: 10,
edgeMargin: 10
},
serviceLine: {
width: 1,
color: '#ffffff'
color: COURT_TOOL_PALETTE.line
},
startCircles: {
radius: 12,
x: 10,
topYOffset: 30,
bottomYOffset: 30,
selectedColor: '#ff0000',
unselectedColor: '#808080',
selectedBorderColor: '#ffffff',
unselectedBorderColor: '#000000',
selectedColor: COURT_TOOL_PALETTE.startSelected,
unselectedColor: COURT_TOOL_PALETTE.startMuted,
selectedBorderColor: COURT_TOOL_PALETTE.frame,
unselectedBorderColor: COURT_TOOL_PALETTE.frameBorder,
selectedBorderWidth: 2,
unselectedBorderWidth: 1
},
@@ -313,14 +331,14 @@ export default {
middleXOffset: 40,
topYOffset: 40,
bottomYOffset: 40,
selectedColor: '#00ff00',
unselectedColor: '#ffffff',
selectedBorderColor: '#ffffff',
unselectedBorderColor: '#000000',
selectedColor: COURT_TOOL_PALETTE.targetSelected,
unselectedColor: COURT_TOOL_PALETTE.frame,
selectedBorderColor: COURT_TOOL_PALETTE.frame,
unselectedBorderColor: COURT_TOOL_PALETTE.targetBorder,
selectedBorderWidth: 2,
unselectedBorderWidth: 1,
font: '12px Arial',
textColor: '#000000',
textColor: COURT_TOOL_PALETTE.targetText,
transparency: 0.3
},
leftTargetCircles: {
@@ -329,17 +347,17 @@ export default {
rightXOffset: 40,
topYOffset: 40,
bottomYOffset: 40,
selectedColor: '#00ff00',
unselectedColor: '#ffffff',
selectedBorderColor: '#00ff00',
unselectedBorderColor: '#000000',
selectedColor: COURT_TOOL_PALETTE.targetSelected,
unselectedColor: COURT_TOOL_PALETTE.frame,
selectedBorderColor: COURT_TOOL_PALETTE.targetSelected,
unselectedBorderColor: COURT_TOOL_PALETTE.targetBorder,
selectedBorderWidth: 2,
unselectedBorderWidth: 1,
font: '12px Arial',
textColor: '#000000'
textColor: COURT_TOOL_PALETTE.targetText
},
arrow: {
color: '#ff0000', // Hauptschlag immer rot
color: COURT_TOOL_PALETTE.arrowPrimary,
width: 6,
cap: 'round',
headSize: 8,
@@ -351,7 +369,7 @@ export default {
counterOffset: 15
},
pen: {
color: '#ff0000',
color: COURT_TOOL_PALETTE.arrowPrimary,
width: 3,
cap: 'round',
join: 'round'
@@ -480,7 +498,7 @@ export default {
if (forceRedraw || isEmpty) {
// Hintergrund
ctx.fillStyle = '#f0f0f0';
ctx.fillStyle = COURT_TOOL_PALETTE.canvasSurface;
ctx.fillRect(0, 0, canvas.width, canvas.height);
} else {
}
@@ -869,8 +887,8 @@ export default {
}
// Pfeil zeichnen (von rechts nach links = blau)
ctx.strokeStyle = '#007bff'; // Blau
ctx.fillStyle = '#007bff'; // Blau
ctx.strokeStyle = COURT_TOOL_PALETTE.arrowSupport;
ctx.fillStyle = COURT_TOOL_PALETTE.arrowSupport;
ctx.lineWidth = config.arrow.width;
ctx.lineCap = config.arrow.cap;
@@ -883,7 +901,7 @@ export default {
const endY = targetY;
// Übungsnummer über der Linie zeichnen
ctx.fillStyle = '#007bff'; // Blau
ctx.fillStyle = COURT_TOOL_PALETTE.arrowSupport;
ctx.font = config.arrow.counterFont;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
@@ -1011,7 +1029,12 @@ export default {
if (!this.additionalStrokes || this.additionalStrokes.length === 0) return;
const max = this.additionalStrokes.length;
// 1: rot, 2: blau, 3: gelb, 4: violett, ab 5: pink
const colors = ['#007bff', '#FFD700', '#6a1b9a', '#ff69b4'];
const colors = [
COURT_TOOL_PALETTE.arrowSupport,
COURT_TOOL_PALETTE.arrowHighlight,
COURT_TOOL_PALETTE.arrowAlternate,
COURT_TOOL_PALETTE.arrowAccent
];
const rightRadius = this.config.targetCircles.radius;
const leftRadius = this.config.leftTargetCircles.radius;
@@ -1026,7 +1049,7 @@ export default {
const side = i % 2 === 0 ? 'left' : 'right';
const targetNum = parseInt(stroke.targetPosition);
// Farben: 2. blau, 3. gelb, 4. violett, ab dem 5. pink
const color = (i < colors.length) ? colors[i] : '#ff69b4';
const color = (i < colors.length) ? colors[i] : COURT_TOOL_PALETTE.arrowAccent;
let toCenter, toPoint;
if (side === 'left') {
toCenter = this.computeLeftTargetCenter(tableX, tableY, tableWidth, tableHeight, targetNum);
@@ -1619,11 +1642,12 @@ export default {
<style scoped>
.court-drawing-tool {
border: 1px solid #ddd;
border-radius: 8px;
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 1rem;
background: white;
background: var(--surface-color, #ffffff);
margin: 1rem 0;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.06);
}
.tool-header {
@@ -1635,7 +1659,7 @@ export default {
.tool-header h4 {
margin: 0;
color: #333;
color: var(--text-color);
}
.tool-controls {
@@ -1646,29 +1670,31 @@ export default {
.canvas-container {
text-align: center;
margin: 1rem 0;
border: 2px solid #ddd;
border-radius: 4px;
background: #f9f9f9;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--background-soft);
padding: 0.75rem;
}
canvas {
border: 1px solid #ccc;
border-radius: 4px;
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: crosshair;
background: white;
background: var(--surface-color, #ffffff);
}
.btn-small {
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
border: 1px solid #ddd;
border-radius: 999px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s;
transition: transform 0.2s ease, opacity 0.2s ease, border-color 0.2s ease;
min-width: 2.5rem;
text-align: center;
background: var(--surface-color, #ffffff);
color: var(--text-color);
}
.btn-primary {
@@ -1679,20 +1705,20 @@ canvas {
.btn-primary:hover {
opacity: 0.95;
transform: translateY(-1px);
}
.btn-secondary {
background-color: #6c757d;
color: white;
border-color: #6c757d;
background: var(--surface-color, #ffffff);
color: var(--text-color);
border-color: var(--border-color);
}
.btn-secondary:hover {
background-color: #5a6268;
border-color: #5a6268;
border-color: var(--primary-color);
transform: translateY(-1px);
}
/* Schlagart-Buttons (VH/RH) - Grün */
.btn-stroke {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: var(--text-on-primary);
@@ -1701,39 +1727,41 @@ canvas {
.btn-stroke:hover {
opacity: 0.95;
transform: translateY(-1px);
}
.btn-stroke.btn-secondary {
background-color: #6c757d;
color: white;
border-color: #6c757d;
background: var(--surface-color, #ffffff);
color: var(--text-color);
border-color: var(--border-color);
}
.btn-stroke.btn-secondary:hover {
background-color: #5a6268;
border-color: #5a6268;
border-color: var(--primary-color);
transform: translateY(-1px);
}
/* Schlagtyp-Buttons (US/OS/TS/FL/BL) - Orange Hintergrund */
.btn-stroke-type {
background: #fd7e14;
color: white;
background: linear-gradient(135deg, #b56a1d, #8f5316);
color: var(--text-on-primary);
border-color: transparent;
}
.btn-stroke-type:hover {
background: #e8650e;
border-color: #e8650e;
opacity: 0.95;
transform: translateY(-1px);
}
.btn-stroke-type.btn-secondary {
color: white !important;
opacity: 0.6;
background: rgba(181, 106, 29, 0.12);
color: #8f5316 !important;
border-color: rgba(181, 106, 29, 0.22);
opacity: 1;
}
.btn-stroke-type.btn-secondary:hover {
background-color: #e8650e !important;
border-color: #e8650e !important;
opacity: 0.8;
background: rgba(181, 106, 29, 0.18);
border-color: rgba(181, 106, 29, 0.3) !important;
}
.group-label {
@@ -1741,7 +1769,7 @@ canvas {
margin: 0 0 4px 0;
font-size: 12px;
font-weight: 600;
color: #495057;
color: var(--text-muted);
}
input[type="color"] {
@@ -1759,14 +1787,14 @@ input[type="range"] {
.exercise-selection {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #ddd;
background: var(--background-soft);
border-radius: 12px;
border: 1px solid var(--border-color);
}
.exercise-selection h5 {
margin: 0 0 1rem 0;
color: #333;
color: var(--text-color);
}
.selection-group {
@@ -1787,7 +1815,7 @@ input[type="range"] {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #495057;
color: var(--text-muted);
}
.stroke-buttons {
@@ -1804,7 +1832,7 @@ input[type="range"] {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #495057;
color: var(--text-muted);
}
.spin-buttons {
@@ -1834,10 +1862,10 @@ input[type="range"] {
}
.exercise-info {
background: white;
background: var(--surface-color, #ffffff);
padding: 0.75rem;
border-radius: 4px;
border: 1px solid #ccc;
border-radius: 10px;
border: 1px solid var(--border-color);
}
.exercise-info p {
@@ -1862,29 +1890,33 @@ input[type="range"] {
padding: 0;
margin: 0;
border: none;
border-radius: 2px;
background: var(--primary-color);
color: #ffffff;
border-radius: 6px;
background: var(--primary-soft);
color: var(--primary-dark);
font-size: 12px;
font-weight: 500;
line-height: 1;
cursor: pointer;
border: 1px solid rgba(29, 90, 61, 0.14);
}
.grid-btn.is-active {
color: #FFD700; /* gelb */
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: var(--text-on-primary);
font-weight: 700; /* fett */
}
/* Ausgewählte Aufschlag-/Seiten-Buttons: fette gelbe Schrift */
.btn-stroke.btn-primary {
color: #FFD700 !important;
color: COURT_TOOL_PALETTE.accentText !important;
font-weight: 700;
}
/* Ausgewählte Schlagtyp-Buttons: fette gelbe Schrift */
.btn-stroke-type.btn-primary {
color: #FFD700 !important;
color: COURT_TOOL_PALETTE.accentText !important;
font-weight: 700;
}
.add-stroke-button {
align-self: flex-end;
}
</style>

View File

@@ -27,15 +27,15 @@
<span>{{ $t('matchReportApi.general') }}</span>
</button>
<button class="section-btn" :class="{ active: activeSection === 'homeLineup', certified: isHomeLineupCertified }"
@click="setActiveSection('homeLineup')">
<button class="section-btn" :class="{ active: activeSection === 'homeLineup', certified: isHomeLineupCertified, disabled: teamNotAppeared === 'home' }"
@click="setActiveSection('homeLineup')" :disabled="teamNotAppeared === 'home'">
<div class="section-icon">👥</div>
<span>{{ $t('matchReportApi.homeLineup') }}</span>
<span v-if="isHomeLineupCertified" class="certified-badge"></span>
</button>
<button class="section-btn" :class="{ active: activeSection === 'guestLineup', certified: isGuestLineupCertified }"
@click="setActiveSection('guestLineup')">
<button class="section-btn" :class="{ active: activeSection === 'guestLineup', certified: isGuestLineupCertified, disabled: teamNotAppeared === 'guest' }"
@click="setActiveSection('guestLineup')" :disabled="teamNotAppeared === 'guest'">
<div class="section-icon">👥</div>
<span>{{ $t('matchReportApi.guestLineup') }}</span>
<span v-if="isGuestLineupCertified" class="certified-badge"></span>
@@ -48,15 +48,15 @@
<span v-if="isGreetingCompleted" class="completed-badge"></span>
</button>
<button class="section-btn" :class="{ active: activeSection === 'result', disabled: !canOpenNextStages }"
@click="setActiveSection('result')" :disabled="!canOpenNextStages">
<button class="section-btn" :class="{ active: activeSection === 'result', disabled: !canOpenResultStage }"
@click="setActiveSection('result')" :disabled="!canOpenResultStage">
<div class="section-icon"></div>
<span>{{ $t('matchReportApi.result') }}</span>
<span v-if="isMatchCompleted" class="locked-indicator">🔒</span>
</button>
<button class="section-btn" :class="{ active: activeSection === 'completion', disabled: !canOpenNextStages }"
@click="setActiveSection('completion')" :disabled="!canOpenNextStages">
<button class="section-btn" :class="{ active: activeSection === 'completion', disabled: !canOpenCompletionStage }"
@click="setActiveSection('completion')" :disabled="!canOpenCompletionStage">
<div class="section-icon"></div>
<span>{{ $t('matchReportApi.completion') }}</span>
</button>
@@ -143,6 +143,7 @@
<label for="homePin">{{ $t('matchReportApi.homePin') }}:</label>
<div class="pin-input-wrapper">
<input id="homePin" v-model="homePin" type="password" :class="pinInputClasses('home')"
:disabled="isLineupLocked('home')"
@input="onPinChange('home', $event)" />
<button @click="signLineup('home')" :class="signButtonClasses('home')"
:disabled="!canSignLineup('home')">
@@ -236,8 +237,7 @@
<div class="pin-input-wrapper">
<input id="guestPin" v-model="guestPin" type="password" :class="pinInputClasses('guest')"
autocomplete="new-password" autocapitalize="off" spellcheck="false"
:readonly="!(match && match.guestPin)"
@focus="(match && match.guestPin) ? null : (guestPin='', $event.target.removeAttribute('readonly'))"
:disabled="isLineupLocked('guest')"
@input="onPinChange('guest', $event)" />
<button @click="signLineup('guest')" :class="signButtonClasses('guest')"
:disabled="!canSignLineup('guest')">
@@ -569,7 +569,7 @@
v-model="finalHomePin"
type="password"
class="pin-input"
:placeholder="match.homePin || 'PIN eingeben'"
placeholder="PIN eingeben"
:disabled="isMatchCompleted || teamNotAppeared === 'home'"
/>
<span v-if="teamNotAppeared === 'home'" class="pin-not-required">(nicht erforderlich - Mannschaft nicht angetreten)</span>
@@ -582,7 +582,7 @@
v-model="finalGuestPin"
type="password"
class="pin-input"
:placeholder="match.guestPin || 'PIN eingeben'"
placeholder="PIN eingeben"
:disabled="isMatchCompleted || teamNotAppeared === 'guest'"
/>
<span v-if="teamNotAppeared === 'guest'" class="pin-not-required">(nicht erforderlich - Mannschaft nicht angetreten)</span>
@@ -594,8 +594,8 @@
<button
@click="submitMatchReport"
class="btn-primary submit-btn"
:disabled="isMatchCompleted || !areAllMatchResultsValid() || !areStartAndEndTimesValid()"
:class="{ 'disabled': isMatchCompleted || !areAllMatchResultsValid() || !areStartAndEndTimesValid() }"
:disabled="isSubmitDisabled"
:class="{ 'disabled': isSubmitDisabled }"
>
{{ getSubmitButtonText() }}
</button>
@@ -646,10 +646,10 @@
<button @click="closePinModal" class="pin-modal-close">&times;</button>
</div>
<div class="pin-modal-body">
<p>Diese Aufstellung ist zertifiziert. Bitte geben Sie die PIN für beide Mannschaften ein:</p>
<p>Diese Aufstellung ist zertifiziert. Bitte geben Sie die passende PIN ein, um sie wieder zu bearbeiten.</p>
<div class="pin-modal-inputs">
<div class="pin-input-group">
<div v-if="pinModalRequiresHomePin()" class="pin-input-group">
<label for="modalHomePin">PIN Heimmannschaft:</label>
<input
id="modalHomePin"
@@ -664,7 +664,7 @@
</small>
</div>
<div class="pin-input-group">
<div v-if="pinModalRequiresGuestPin()" class="pin-input-group">
<label for="modalGuestPin">PIN Gastmannschaft:</label>
<input
id="modalGuestPin"
@@ -835,18 +835,29 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
if (this.isMatchCompleted) {
return false;
}
// WO-Fall: keine Ergebniserfassung, Abschluss darf aber direkt geöffnet werden
if (this.teamNotAppeared !== null) {
return true;
}
// Wenn eine Mannschaft nicht angetreten ist, muss die andere Mannschaft ihre Aufstellung signieren
if (this.teamNotAppeared === 'home') {
// Heim nicht angetreten -> Gast muss Aufstellung signieren
return this.isGuestLineupCertified;
}
if (this.teamNotAppeared === 'guest') {
// Gast nicht angetreten -> Heim muss Aufstellung signieren
return this.isHomeLineupCertified;
}
// Beide Mannschaften angetreten -> beide müssen Aufstellung signieren
return this.isHomeLineupCertified && this.isGuestLineupCertified;
},
canOpenResultStage() {
if (this.teamNotAppeared !== null) {
return false;
}
return this.canOpenNextStages;
},
canOpenCompletionStage() {
if (this.isMatchCompleted) {
return false;
}
if (this.teamNotAppeared !== null) {
return true;
}
return this.canOpenNextStages;
},
currentPlayMode() {
return (this.meetingDetails && this.meetingDetails.playMode) || (this.meetingData && this.meetingData.playMode) || '';
},
@@ -874,8 +885,25 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Prüfe ob das Match bereits abgeschlossen ist
isMatchCompleted() {
if (typeof this.meetingDetails?.isCompleted === 'boolean') {
return this.meetingDetails.isCompleted;
}
if (typeof this.meetingData?.isCompleted === 'boolean') {
return this.meetingData.isCompleted;
}
return this.match && this.match.isCompleted === true;
},
isSubmitDisabled() {
if (this.isMatchCompleted) {
return true;
}
if (this.teamNotAppeared !== null) {
return false;
}
return !this.areAllMatchResultsValid() || !this.areStartAndEndTimesValid();
}
},
async mounted() {
await this.loadData();
@@ -911,7 +939,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
watch: {
teamNotAppeared(newValue, oldValue) {
if (newValue !== oldValue && this.meetingData) {
if (newValue === oldValue) {
return;
}
if (this.meetingData) {
if (newValue === null) {
// Entferne das wo-Flag beim Zurücksetzen
this.meetingData.wo = null;
@@ -920,9 +952,43 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
this.applyTeamNotAppeared();
}
}
if (newValue === 'home') {
this.isHomeLineupCertified = false;
this.homePin = '';
this.finalHomePin = '';
this.activeSection = 'completion';
return;
}
if (newValue === 'guest') {
this.isGuestLineupCertified = false;
this.guestPin = '';
this.finalGuestPin = '';
this.activeSection = 'completion';
return;
}
if (newValue === null && this.activeSection === 'completion' && !this.canOpenNextStages) {
this.activeSection = 'general';
}
}
},
methods: {
logSignatureState(label, payload) {
const signature = payload?.signature || null;
console.log(`[match-report] ${label}`, {
wo: payload?.wo ?? null,
isCompleted: payload?.isCompleted ?? null,
homePin: payload?.homePin ?? null,
guestPin: payload?.guestPin ?? null,
releaseSignatureHome: signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: signature?.lineupSignatureGuest ?? null
});
},
// Effektive Spieleranzahl (berücksichtigt Braunschweiger-Regel)
getEffectivePlayerCount(team) {
const actualCount = this.getSelectedPlayerCount(team);
@@ -1140,6 +1206,17 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Intelligente Navigation nach Zertifizierung
navigateAfterCertification() {
console.log('🧭 Intelligente Navigation nach Zertifizierung...');
if (this.teamNotAppeared !== null) {
if (this.canOpenNextStages) {
this.setActiveSection('completion');
console.log('🎯 WO-Fall: angetretene Mannschaft signiert → Abschluss');
} else {
this.setActiveSection('general');
console.log('🎯 WO-Fall: warte auf Signatur der angetretenen Mannschaft');
}
return;
}
// Prüfe ob beide Aufstellungen jetzt zertifiziert sind
const bothCertified = this.isHomeLineupCertified && this.isGuestLineupCertified;
@@ -1295,9 +1372,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
initializeFinalPins() {
// Initialisiere PIN-Felder mit den Werten aus dem Match-Objekt
this.finalHomePin = this.match.homePin || '';
this.finalGuestPin = this.match.guestPin || '';
// Nur lokale Klartext-PINs vorbefuellen, niemals nuscore-Hashes.
this.finalHomePin = this.isLikelyPinHash(this.match.homePin) ? '' : (this.match.homePin || '');
this.finalGuestPin = this.isLikelyPinHash(this.match.guestPin) ? '' : (this.match.guestPin || '');
},
// Text für den Absenden-Button basierend auf Zustand
@@ -1464,17 +1541,17 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
console.log('⚠️ Mannschaft nicht angetreten - überspringe Match-Ergebnis- und Zeit-Validierung');
}
// Validiere Aufstellungen (nur für angetretene Mannschaften)
// WO-Fall: keine Aufstellungssignaturen erzwingen
const isHomeNotAppeared = this.teamNotAppeared === 'home';
const isGuestNotAppeared = this.teamNotAppeared === 'guest';
if (!isHomeNotAppeared && !this.isHomeLineupCertified) {
if (this.teamNotAppeared === null && !isHomeNotAppeared && !this.isHomeLineupCertified) {
alert('❌ Der Spielbericht kann nicht abgesendet werden, da die Heimmannschaft ihre Aufstellung noch nicht signiert hat.\n\n' +
'Bitte signieren Sie die Aufstellung der Heimmannschaft, bevor Sie den Spielbericht absenden.');
return;
}
if (!isGuestNotAppeared && !this.isGuestLineupCertified) {
if (this.teamNotAppeared === null && !isGuestNotAppeared && !this.isGuestLineupCertified) {
alert('❌ Der Spielbericht kann nicht abgesendet werden, da die Gastmannschaft ihre Aufstellung noch nicht signiert hat.\n\n' +
'Bitte signieren Sie die Aufstellung der Gastmannschaft, bevor Sie den Spielbericht absenden.');
return;
@@ -1505,6 +1582,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
console.log('🔄 Aktualisiere Match-Daten...');
this.updateMatchData(matchData);
console.log('✅ Match-Daten aktualisiert');
this.logSignatureState('payload before validate', matchData);
// Für WebSocket-Broadcast: clubId und gameCode mitsenden
const clubId = this.$store?.getters?.currentClub;
@@ -1519,18 +1597,35 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
throw new Error('nuLigaMeetingUuid nicht gefunden');
}
const validationResult = await this.validateReport(matchData);
if (validationResult?.resultState === 'VALIDATION_ERROR') {
const validationMessage = Array.isArray(validationResult.validationErrors) && validationResult.validationErrors.length > 0
? validationResult.validationErrors.join('\n')
: 'Validierung fehlgeschlagen';
throw new Error(validationMessage);
}
const submitPayload = validationResult?.object
? JSON.parse(JSON.stringify(validationResult.object))
: matchData;
this.logSignatureState('payload before submit', submitPayload);
const response = await fetch(`${backendBaseUrl}/api/nuscore/submit/${uuid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(matchData)
body: JSON.stringify(submitPayload)
});
const result = await response.json();
console.log('[match-report] submit response', result);
if (!response.ok) {
throw new Error(result.error || `HTTP ${response.status}: ${response.statusText}`);
const validationMessage = Array.isArray(result?.details?.validationErrors) && result.details.validationErrors.length > 0
? result.details.validationErrors.join('\n')
: null;
throw new Error(validationMessage || result.error || `HTTP ${response.status}: ${response.statusText}`);
}
console.log('✅ Spielbericht erfolgreich abgesendet:', result);
@@ -1546,18 +1641,20 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
async validateReport() {
async validateReport(matchDataOverride = null) {
try {
if (!this.meetingData || !this.meetingData.nuLigaMeetingUuid) {
console.warn('⚠️ Keine Meeting-UUID verfügbar für Validierung');
return;
return null;
}
// Erstelle eine Kopie des aktuellen Match-Objekts
const matchData = JSON.parse(JSON.stringify(this.match));
// Aktualisiere die Match-Daten mit unseren Eingaben
this.updateMatchData(matchData);
const matchData = matchDataOverride
? JSON.parse(JSON.stringify(matchDataOverride))
: JSON.parse(JSON.stringify(this.match));
if (!matchDataOverride) {
this.updateMatchData(matchData);
}
const uuid = this.meetingData.nuLigaMeetingUuid;
@@ -1572,7 +1669,17 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
});
const result = await response.json();
console.log('[match-report] validate response', result);
if (result?.object) {
this.meetingDetails = JSON.parse(JSON.stringify(result.object));
this.meetingData = {
...(this.meetingData || {}),
...JSON.parse(JSON.stringify(result.object))
};
this.applyLineupCertificationFromMeetingDetails();
}
if (response.ok) {
console.log('✅ Validierung erfolgreich:', result);
@@ -1588,10 +1695,13 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
} else {
console.warn('⚠️ Validierung mit Fehlern:', result);
}
return result;
} catch (error) {
console.error('❌ Fehler beim Validieren:', error);
// Validierungsfehler sind nicht kritisch, daher kein Alert
return null;
}
},
@@ -1626,14 +1736,22 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// NUR unsere spezifischen Änderungen eintragen:
// Wenn eine Mannschaft nicht angetreten ist, nur wo-Flag setzen und nichts anderes ändern
const isHomeNotAppeared = this.teamNotAppeared === 'home';
const isGuestNotAppeared = this.teamNotAppeared === 'guest';
// Abschluss-PINs explizit auf die korrekten Team-Felder schreiben.
// Im WO-Fall nur fuer die angetretene Mannschaft.
if (!isHomeNotAppeared && this.finalHomePin && this.finalHomePin.trim() !== '') {
matchData.homePin = this.finalHomePin.trim();
}
if (!isGuestNotAppeared && this.finalGuestPin && this.finalGuestPin.trim() !== '') {
matchData.guestPin = this.finalGuestPin.trim();
}
// Wenn eine Mannschaft nicht angetreten ist, nur wo-Flag und PIN der angetretenen Mannschaft setzen
if (this.teamNotAppeared !== null) {
// Nur das wo-Flag ist bereits gesetzt (durch applyTeamNotAppeared)
// Alle anderen Daten bleiben unverändert (Original aus baseData)
// isCompleted bleibt false, PINs bleiben unverändert, keine Match-Ergebnisse ändern
// Keine Zeitangaben, Spieleranzahl, Positionen oder Protest-Informationen ändern
console.log('⚠️ Mannschaft nicht angetreten - nur wo-Flag wird gesetzt, alle anderen Daten bleiben unverändert');
return; // Frühzeitiger Return - keine weiteren Änderungen
console.log('⚠️ Mannschaft nicht angetreten - nur wo-Flag und PIN der angetretenen Mannschaft werden gesetzt');
return;
}
// 1. Spieleranzahl aktualisieren (aus Aufstellung)
@@ -2068,23 +2186,118 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
/**
* Setzt die Aufstellungs-Bestätigung (certified) aus den Meeting-Details.
* In nuscore wird nach PIN-Eingabe ein Hash gespeichert (homePin/guestPin).
* Ist dieser gesetzt, gilt die Aufstellung als bereits bestätigt.
* Wichtig: NICHT aus homePin/guestPin ableiten. Diese Hashes koennen bereits
* vor einer bestaetigten Aufstellung vorhanden sein und sind nur fuer die
* PIN-Validierung gedacht.
*/
applyLineupCertificationFromMeetingDetails() {
if (!this.meetingDetails) return;
const homePinSet = this.meetingDetails.homePin != null && String(this.meetingDetails.homePin).trim() !== '';
const guestPinSet = this.meetingDetails.guestPin != null && String(this.meetingDetails.guestPin).trim() !== '';
if (homePinSet) {
this.isHomeLineupCertified = true;
const state = this.getExplicitLineupCertificationState();
if (state.home !== null) {
this.isHomeLineupCertified = state.home;
}
if (guestPinSet) {
this.isGuestLineupCertified = true;
if (state.guest !== null) {
this.isGuestLineupCertified = state.guest;
}
},
normalizeCertificationValue(value) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (value === 1) return true;
if (value === 0) return false;
return null;
}
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
const trueValues = new Set(['true', '1', 'yes', 'done', 'signed', 'confirmed', 'certified', 'complete', 'completed', 'reviewed', 'locked', 'readonly', 'read-only']);
const falseValues = new Set(['false', '0', 'no', 'open', 'editable', 'pending', 'draft', 'unlocked']);
if (trueValues.has(normalized)) return true;
if (falseValues.has(normalized)) return false;
return null;
},
readValueByPath(source, path) {
return path.split('.').reduce((current, segment) => {
if (current == null || typeof current !== 'object') {
return undefined;
}
return current[segment];
}, source);
},
extractTeamCertificationFromSource(source, team) {
if (!source || typeof source !== 'object') return null;
const lineupSignaturePath = team === 'home'
? 'signature.lineupSignatureHome'
: 'signature.lineupSignatureGuest';
const lineupSignatureValue = this.readValueByPath(source, lineupSignaturePath);
if (lineupSignatureValue !== undefined) {
return typeof lineupSignatureValue === 'string'
? lineupSignatureValue.trim() !== ''
: Boolean(lineupSignatureValue);
}
if (Object.prototype.hasOwnProperty.call(source, 'signature') && source.signature === null) {
return false;
}
const teamMarkers = team === 'home'
? ['home', 'cluba', 'teama', 'lineuphome', 'homelineup']
: ['guest', 'clubb', 'teamb', 'lineupguest', 'guestlineup'];
const statusMarkers = ['certified', 'signed', 'confirmed', 'reviewed', 'clearance', 'locked', 'readonly', 'signature'];
const walk = (value, path = []) => {
if (!value || typeof value !== 'object') return null;
for (const [rawKey, child] of Object.entries(value)) {
const key = String(rawKey);
const nextPath = [...path, key];
const joinedPath = nextPath.join('.').toLowerCase();
if (!joinedPath.includes('pin') &&
teamMarkers.some(marker => joinedPath.includes(marker)) &&
statusMarkers.some(marker => joinedPath.includes(marker))) {
if (joinedPath.includes('lineupsignature') && typeof child === 'string') {
return child.trim() !== '';
}
const normalized = this.normalizeCertificationValue(child);
if (normalized !== null) {
return normalized;
}
}
if (child && typeof child === 'object') {
const nested = walk(child, nextPath);
if (nested !== null) {
return nested;
}
}
}
return null;
};
return walk(source);
},
getExplicitLineupCertificationState() {
const state = { home: null, guest: null };
for (const source of [this.meetingDetails, this.meetingData]) {
if (state.home === null) {
state.home = this.extractTeamCertificationFromSource(source, 'home');
}
if (state.guest === null) {
state.guest = this.extractTeamCertificationFromSource(source, 'guest');
}
}
return state;
},
/** Entwurf für Broadcast: meetingDetails-Form mit aktuellen Satzergebnissen aus results. */
getDraftMatchData() {
if (!this.meetingDetails || !Array.isArray(this.meetingDetails.matches)) return null;
@@ -2961,6 +3174,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
togglePlayerSelection(player, team) {
if (this.isLineupLocked(team)) return;
if (!this.meetingDetails) return;
const teamKey = team === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers';
@@ -2982,6 +3196,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
toggleDoublePosition(player, team, doublePosition) {
if (this.isLineupLocked(team)) return;
if (!this.meetingDetails) return;
const teamKey = team === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers';
@@ -3074,6 +3289,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
onPinChange(team, event) {
if (this.isLineupLocked(team)) {
return;
}
const pin = event.target.value;
if (team === 'home') {
this.homePin = pin;
@@ -3158,6 +3376,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Aufstellung signieren
async signLineup(team) {
if (this.isLineupLocked(team)) {
return;
}
const pin = team === 'home' ? this.homePin : this.guestPin;
if (!pin) {
@@ -3345,6 +3566,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
canSignLineup(team) {
if (this.isLineupLocked(team)) {
return false;
}
const pin = team === 'home' ? this.homePin : this.guestPin;
// PIN prüfen
@@ -3586,14 +3810,20 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// PIN-Modal für zertifizierte Aufstellungen
openPinModal(team) {
this.pinModalTeam = team;
// Heim-PIN nur vorausfüllen, wenn sie ursprünglich verwendet wurde
this.pinModalHomePin = this.originalHomePin || '';
// Gast-PIN nur vorausfüllen, wenn sie ursprünglich verwendet wurde UND vom Frontend-Match geliefert wurde (echte PIN)
this.pinModalGuestPin = (this.originalGuestPin && this.match && this.match.guestPin) ? this.originalGuestPin : '';
this.pinModalGuestPin = this.originalGuestPin || '';
this.pinModalError = '';
this.showPinModal = true;
},
pinModalRequiresHomePin() {
return this.pinModalTeam === 'homeLineup' || this.pinModalTeam === 'home';
},
pinModalRequiresGuestPin() {
return this.pinModalTeam === 'guestLineup' || this.pinModalTeam === 'guest';
},
closePinModal() {
this.showPinModal = false;
this.pinModalTeam = null;
@@ -3603,40 +3833,47 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
async submitPinModal() {
// Beide PINs sind Pflicht
if (!this.pinModalHomePin || !this.pinModalGuestPin) {
this.pinModalError = 'Bitte geben Sie die Heim- und Gast-PIN ein';
const requiresHomePin = this.pinModalRequiresHomePin();
const requiresGuestPin = this.pinModalRequiresGuestPin();
if (requiresHomePin && !this.pinModalHomePin) {
this.pinModalError = 'Bitte geben Sie die Heim-PIN ein';
return;
}
// Prüfen gegen ursprünglich verwendete PINs
const homePinValid = this.pinModalHomePin === this.originalHomePin;
const guestPinValid = this.pinModalGuestPin === this.originalGuestPin;
if (requiresGuestPin && !this.pinModalGuestPin) {
this.pinModalError = 'Bitte geben Sie die Gast-PIN ein';
return;
}
const homePinValid = !requiresHomePin || await this.validatePin('home', this.pinModalHomePin);
const guestPinValid = !requiresGuestPin || await this.validatePin('guest', this.pinModalGuestPin);
if (homePinValid && guestPinValid) {
console.log('✅ PIN erfolgreich - entsperre Aufstellung und wechsle Tab');
// Nur den angeforderten Tab entsperren
if (this.pinModalTeam === 'homeLineup' || this.pinModalTeam === 'home') {
if (requiresHomePin) {
this.isHomeLineupCertified = false;
this.closePinModal();
// Wechsle sofort zu Heim-Aufstellungs-Tab
this.setActiveSection('homeLineup');
console.log('🎯 Wechsle zu Heim-Aufstellungs-Tab');
} else if (this.pinModalTeam === 'guestLineup' || this.pinModalTeam === 'guest') {
this.isGuestLineupCertified = false;
this.closePinModal();
// Wechsle sofort zu Gast-Aufstellungs-Tab
this.setActiveSection('guestLineup');
console.log('🎯 Wechsle zu Gast-Aufstellungs-Tab');
} else {
this.closePinModal();
}
if (requiresGuestPin) {
this.isGuestLineupCertified = false;
}
const targetSection = requiresHomePin ? 'homeLineup' : 'guestLineup';
this.closePinModal();
this.activeSection = targetSection;
console.log(`🎯 Wechsle zu ${targetSection}`);
} else {
this.pinModalError = 'Eine oder beide PINs sind nicht korrekt';
this.pinModalError = requiresHomePin && requiresGuestPin
? 'Eine oder beide PINs sind nicht korrekt'
: (requiresHomePin ? 'Die Heim-PIN ist nicht korrekt' : 'Die Gast-PIN ist nicht korrekt');
}
},
isLineupLocked(team) {
return team === 'home' ? this.isHomeLineupCertified : this.isGuestLineupCertified;
},
// Prüfe ob Aufstellung-Tab geöffnet werden kann
canOpenLineupTab(team) {
if (team === 'home') {
@@ -3648,6 +3885,15 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Tab-Wechsel mit PIN-Prüfung
setActiveSection(section) {
if ((section === 'homeLineup' && this.teamNotAppeared === 'home') ||
(section === 'guestLineup' && this.teamNotAppeared === 'guest')) {
return;
}
if (section === 'result' && this.teamNotAppeared !== null) {
return;
}
if ((section === 'homeLineup' && this.isHomeLineupCertified) ||
(section === 'guestLineup' && this.isGuestLineupCertified)) {
this.openPinModal(section);
@@ -3672,6 +3918,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
hour: '2-digit',
minute: '2-digit'
});
},
isLikelyPinHash(value) {
return typeof value === 'string' && /^[a-f0-9]{64,}$/i.test(value.trim());
}
}
};

View File

@@ -8,7 +8,7 @@
<button @click="showAnalyzer = !showAnalyzer" class="toggle-btn">
{{ showAnalyzer ? '🔼 ' + $t('matchReport.hideAnalyzer') : '🔍 ' + $t('matchReport.analyzeNuscore') }}
</button>
<button @click="createLocalCopy" class="toggle-btn" style="margin-left: 10px;">
<button @click="createLocalCopy" class="toggle-btn toggle-btn-secondary">
💾 {{ $t('matchReport.createLocalCopy') }}
</button>
</div>
@@ -202,28 +202,37 @@ export default {
}
.analyzer-toggle {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
padding: 10px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
background: var(--background-soft);
border-bottom: 1px solid var(--border-color);
}
.toggle-btn {
background: var(--primary-color);
color: white;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: var(--text-on-primary);
border: none;
padding: 8px 16px;
border-radius: 4px;
border-radius: 999px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.toggle-btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.toggle-btn-secondary {
background: var(--surface-color, white);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.match-info {
background: #f8f9fa;
background: var(--background-soft);
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
@@ -231,19 +240,19 @@ export default {
.match-info h4 {
margin: 0 0 12px 0;
color: #007bff;
color: var(--primary-dark);
font-size: 1.1rem;
}
.match-info p {
margin: 4px 0;
font-size: 0.9rem;
color: #555;
color: var(--text-muted);
}
.code-display {
font-family: 'Courier New', monospace;
background: #e3f2fd;
background: var(--primary-soft);
padding: 2px 6px;
border-radius: 4px;
margin-right: 8px;
@@ -260,21 +269,21 @@ export default {
}
.copy-btn:hover {
background: #f0f0f0;
background: var(--background-soft);
}
.instructions {
background: #f8f9fa;
background: var(--background-soft);
padding: 12px;
border-radius: 6px;
margin-top: 12px;
border-left: 4px solid #007bff;
border-left: 4px solid var(--primary-color);
}
.instructions p {
margin: 0 0 8px 0;
font-weight: 600;
color: #007bff;
color: var(--primary-dark);
}
.instructions ol {
@@ -285,7 +294,7 @@ export default {
.instructions li {
margin: 4px 0;
font-size: 0.9rem;
color: #555;
color: var(--text-muted);
}
.report-content {

View File

@@ -405,7 +405,8 @@ export default {
<style scoped>
.nuscore-analyzer {
padding: 20px;
background: #f8f9fa;
background: var(--background-soft);
border: 1px solid var(--border-color);
border-radius: 8px;
margin: 20px 0;
}
@@ -425,28 +426,29 @@ export default {
.analyze-btn, .download-btn, .create-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
border-radius: 999px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.analyze-btn {
background: var(--primary-color);
color: white;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: var(--text-on-primary);
}
.analyze-btn:hover:not(:disabled) {
background: var(--primary-hover);
transform: translateY(-1px);
}
.download-btn {
background: var(--secondary-color);
color: white;
background: var(--surface-color, white);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.download-btn:hover:not(:disabled) {
background: var(--secondary-hover);
transform: translateY(-1px);
}
.create-btn {
@@ -466,7 +468,7 @@ export default {
.resource-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 4px;
background: white;
}
@@ -475,7 +477,7 @@ export default {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--border-color);
gap: 10px;
}
@@ -484,7 +486,7 @@ export default {
}
.resource-item.downloaded {
background: #d4edda;
background: rgba(46, 125, 50, 0.12);
}
.resource-type {
@@ -497,7 +499,7 @@ export default {
flex: 1;
font-family: monospace;
font-size: 12px;
color: #666;
color: var(--text-muted);
}
.resource-size {
@@ -507,15 +509,15 @@ export default {
}
.status {
color: #28a745;
color: var(--success-color);
font-weight: bold;
}
.log-content {
max-height: 200px;
overflow-y: auto;
background: #000;
color: #0f0;
background: #1b232b;
color: #b9f7c3;
padding: 10px;
border-radius: 4px;
font-family: monospace;

View File

@@ -142,7 +142,7 @@
</button>
</div>
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-content">
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-content active">
<div v-show="activeTab === 'schedule'" class="tab-panel">
<slot name="schedule-panel" />
</div>

View File

@@ -647,20 +647,20 @@ export default {
align-items: center;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: #f3f4f6;
color: #4b5563;
background: var(--background-soft);
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 600;
}
.results-chip-live {
background: #dcfce7;
color: #166534;
background: rgba(46, 125, 50, 0.14);
color: var(--success-color);
}
.results-chip-open {
background: #fef3c7;
color: #92400e;
background: rgba(181, 106, 29, 0.14);
color: #8f5316;
}
.results-section-header {
@@ -676,19 +676,19 @@ export default {
/* Farbmarkierungen für Spiele */
.match-finished {
background-color: #e9ecef !important;
background-color: var(--background-soft) !important;
}
.match-finished td {
color: #626262 !important;
color: var(--text-muted) !important;
}
.match-live {
background-color: #d4edda !important;
background-color: rgba(46, 125, 50, 0.12) !important;
}
.match-live:not(.match-finished) td {
color: #155724 !important;
color: var(--success-color) !important;
}
.match-finished.match-live {
@@ -701,8 +701,8 @@ export default {
/* Aktives Match hervorheben - auch wenn es abgeschlossen ist */
.active-match {
background-color: #fff3cd !important;
border-left: 3px solid #ffc107 !important;
background-color: rgba(181, 106, 29, 0.12) !important;
border-left: 3px solid #b56a1d !important;
}
.active-match.match-finished {
@@ -710,7 +710,7 @@ export default {
}
.active-match.match-finished td {
color: #856404 !important;
color: #8f5316 !important;
}
.active-match.match-live {
@@ -718,24 +718,24 @@ export default {
}
.active-match.match-live td {
color: #856404 !important;
color: #8f5316 !important;
}
.gave-up-result {
color: #626262;
color: var(--text-muted);
}
.gave-up-badge-small {
margin-left: 0.25rem;
font-size: 0.8rem;
color: #721c24;
color: var(--danger-color);
}
.no-edit-hint {
font-size: 0.85rem;
color: #6c757d;
color: var(--text-muted);
}
.active-match:hover {
background-color: #ffe69c !important;
background-color: rgba(181, 106, 29, 0.18) !important;
}
.distribute-tables-bar {
@@ -748,14 +748,14 @@ export default {
margin-top: 1rem;
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border: 1px solid #dbeafe;
border: 1px solid var(--border-color);
border-radius: 12px;
background: #f8fbff;
background: var(--surface-color, #ffffff);
}
.results-next-step-muted {
border-color: #e5e7eb;
background: #f9fafb;
border-color: var(--border-color);
background: var(--background-soft);
}
.ranking-class-gap {
@@ -776,23 +776,23 @@ export default {
}
.status-open {
background: #e5e7eb;
color: #4b5563;
background: var(--background-soft);
color: var(--text-muted);
}
.status-live {
background: #dcfce7;
color: #166534;
background: rgba(46, 125, 50, 0.14);
color: var(--success-color);
}
.status-finished {
background: #dbeafe;
color: #1d4ed8;
background: var(--primary-soft);
color: var(--primary-dark);
}
.status-gaveup {
background: #fee2e2;
color: #991b1b;
background: rgba(200, 76, 50, 0.14);
color: var(--danger-color);
}
.action-row {
@@ -806,8 +806,9 @@ export default {
width: 3.5rem;
padding: 0.15rem 0.25rem;
font-size: 0.85rem;
border: 1px solid #ced4da;
border-radius: 4px;
background: #fff;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--surface-color, #ffffff);
color: var(--text-color);
}
</style>

View File

@@ -593,11 +593,11 @@ export default {
.header h1 {
margin: 0 0 10px 0;
color: #2c3e50;
color: var(--text-color);
}
.subtitle {
color: #7f8c8d;
color: var(--text-muted);
margin: 0;
}
@@ -608,12 +608,13 @@ export default {
}
.error {
background-color: #fee;
color: #c33;
background-color: rgba(180, 66, 66, 0.12);
color: var(--danger-color);
}
.role-legend {
background: #f8f9fa;
background: var(--background-soft);
border: 1px solid var(--border-color);
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
@@ -621,7 +622,7 @@ export default {
.role-legend h3 {
margin-top: 0;
color: #2c3e50;
color: var(--text-color);
}
.roles-grid {
@@ -635,30 +636,31 @@ export default {
background: white;
padding: 15px;
border-radius: 6px;
border: 1px solid #e0e0e0;
border: 1px solid var(--border-color);
}
.role-name {
font-weight: 600;
color: #2c3e50;
color: var(--text-color);
margin-bottom: 5px;
}
.role-description {
font-size: 0.9em;
color: #666;
color: var(--text-muted);
}
.members-table {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-soft);
}
.members-table h3 {
margin-top: 0;
color: #2c3e50;
color: var(--text-color);
}
table {
@@ -670,18 +672,18 @@ table {
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: #f8f9fa;
background-color: var(--background-soft);
font-weight: 600;
color: #2c3e50;
color: var(--text-color);
}
.role-select {
padding: 6px 10px;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.95em;
}
@@ -697,23 +699,23 @@ th {
}
.role-admin {
background-color: #e3f2fd;
color: #1976d2;
background-color: var(--primary-soft);
color: var(--primary-dark);
}
.role-trainer {
background-color: #f3e5f5;
color: #7b1fa2;
background-color: rgba(106, 27, 154, 0.12);
color: #6a1b9a;
}
.role-team_manager {
background-color: #fff3e0;
color: #f57c00;
background-color: rgba(212, 132, 30, 0.14);
color: #9c5700;
}
.role-member {
background-color: #f5f5f5;
color: #616161;
background-color: var(--background-soft);
color: var(--text-muted);
}
.owner-badge {
@@ -729,13 +731,13 @@ th {
}
.status-active {
background-color: #e8f5e9;
color: #2e7d32;
background-color: rgba(46, 125, 50, 0.12);
color: var(--success-color);
}
.status-inactive {
background-color: #ffebee;
color: #c62828;
background-color: rgba(180, 66, 66, 0.12);
color: var(--danger-color);
}
.status-badge.clickable {
@@ -749,8 +751,8 @@ th {
.btn-small {
padding: 6px 12px;
background-color: #2196f3;
color: white;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: var(--text-on-primary);
border: none;
border-radius: 4px;
cursor: pointer;
@@ -758,7 +760,7 @@ th {
}
.btn-small:hover {
background-color: #1976d2;
opacity: 0.95;
}
.muted {
@@ -787,7 +789,7 @@ th {
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-strong);
}
.dialog-header {
@@ -795,12 +797,12 @@ th {
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
border-bottom: 1px solid var(--border-color);
}
.dialog-header h2 {
margin: 0;
color: #2c3e50;
color: var(--text-color);
}
.close-btn {
@@ -808,7 +810,7 @@ th {
border: none;
font-size: 28px;
cursor: pointer;
color: #666;
color: var(--text-muted);
padding: 0;
width: 30px;
height: 30px;
@@ -818,7 +820,7 @@ th {
}
.close-btn:hover {
color: #333;
color: var(--text-color);
}
.dialog-body {
@@ -828,7 +830,7 @@ th {
}
.info-text {
background-color: #f8f9fa;
background-color: var(--background-soft);
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
@@ -841,7 +843,7 @@ th {
}
.permission-group {
border: 1px solid #e0e0e0;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 15px;
}
@@ -854,7 +856,7 @@ th {
.permission-group h4 {
margin: 0 0 12px 0;
color: #2c3e50;
color: var(--text-color);
font-size: 1em;
}
@@ -872,7 +874,7 @@ th {
}
.permission-action-label {
color: #2c3e50;
color: var(--text-color);
}
.permission-checkbox {
@@ -889,7 +891,7 @@ th {
.dialog-footer {
padding: 15px 20px;
border-top: 1px solid #e0e0e0;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 10px;
@@ -904,27 +906,27 @@ th {
}
.btn-primary {
background-color: #2196f3;
color: white;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: var(--text-on-primary);
}
.btn-primary:hover {
background-color: #1976d2;
opacity: 0.95;
}
.btn-secondary {
background-color: #f5f5f5;
color: #333;
background-color: var(--background-soft);
color: var(--text-color);
}
.btn-secondary:hover {
background-color: #e0e0e0;
background-color: var(--surface-hover);
}
.btn-reset {
background: #f5f5f5;
border: 1px solid #ddd;
color: #333;
background: var(--background-soft);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
@@ -944,18 +946,17 @@ th {
}
.state-inherit {
background: #f5f5f5;
color: #333;
background: var(--background-soft);
color: var(--text-color);
}
.state-allow {
background: #e8f5e9;
color: #2e7d32;
background: rgba(46, 125, 50, 0.12);
color: var(--success-color);
}
.state-deny {
background: #ffebee;
color: #c62828;
background: rgba(180, 66, 66, 0.12);
color: var(--danger-color);
}
</style>

View File

@@ -6,6 +6,8 @@
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
"dev:backend": "cd backend && npm run dev",
"dev:frontend": "cd frontend && npm run dev",
"audit:frontend-size": "bash ./scripts/audit_frontend_size.sh",
"audit:inline-todos": "bash ./scripts/audit_inline_todos.sh",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET_DIR="${1:-$ROOT_DIR/frontend/src}"
WARN_BYTES=$((80 * 1024))
REFACTOR_BYTES=$((120 * 1024))
if [[ ! -d "$TARGET_DIR" ]]; then
echo "Directory not found: $TARGET_DIR" >&2
exit 1
fi
echo "Frontend size audit"
echo "Target: $TARGET_DIR"
echo "Warn threshold: ${WARN_BYTES} bytes (~80 KB)"
echo "Refactor threshold: ${REFACTOR_BYTES} bytes (~120 KB)"
echo
find "$TARGET_DIR" -type f \( -name '*.vue' -o -name '*.js' -o -name '*.ts' -o -name '*.scss' \) -printf '%s %p\n' \
| sort -nr \
| awk -v warn="$WARN_BYTES" -v refactor="$REFACTOR_BYTES" '
BEGIN {
printf "%-12s %-12s %s\n", "Size (KB)", "Status", "File";
print "---------------------------------------------------------------";
}
{
size_bytes = $1;
size_kb = size_bytes / 1024;
$1 = "";
sub(/^ /, "", $0);
status = "ok";
if (size_bytes >= refactor) {
status = "refactor";
} else if (size_bytes >= warn) {
status = "warn";
}
printf "%-12.1f %-12s %s\n", size_kb, status, $0;
}
' \
| sed -n '1,40p'

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "Inline TODO/FIXME audit"
echo "Target: $ROOT_DIR/frontend/src + $ROOT_DIR/backend"
echo
if ! rg -n "TODO|FIXME|XXX" \
"$ROOT_DIR/frontend/src" \
"$ROOT_DIR/backend" \
-g '*.vue' \
-g '*.js' \
-g '*.ts' \
-g '*.scss' \
-g '*.sql' \
--glob '!**/dist/**' \
--glob '!**/node_modules/**' \
--glob '!**/package-lock.json'; then
echo "No inline TODO/FIXME/XXX markers found in product code."
fi