Compare commits
2 Commits
60a0928d2f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fdd70fc20d | |||
| 06ea149d41 |
@@ -1,26 +1,56 @@
|
|||||||
# Ressourcenplanung
|
# Ressourcenplanung
|
||||||
|
|
||||||
Leichtgewichtige App zur Planung von Projekten, Aufgaben, Mitarbeitern und Teams –
|
Leichtgewichtige App zur Planung von Projekten, Aufgaben, Mitarbeitern, Teams und
|
||||||
mit Zeitplan (Gantt), Auslastungs-Heatmap und Team-Verwaltung. Daten liegen in SQLite.
|
Urlaub – mit Zeitplan (Gantt), Auslastungs-Heatmap, Team-Verwaltung und Login mit
|
||||||
|
Rollen/Rechten. Daten liegen in SQLite.
|
||||||
|
|
||||||
|
**Breaking Change:** Seit Einführung der Benutzerverwaltung erfordert jeder
|
||||||
|
API-Zugriff eine angemeldete Sitzung (`/health` bleibt für den Docker-Healthcheck
|
||||||
|
offen).
|
||||||
|
|
||||||
|
## Rollen & Rechte
|
||||||
|
|
||||||
|
| Rolle | Rechte |
|
||||||
|
|---|---|
|
||||||
|
| Admin | lesend + schreibend auf alles, inkl. Benutzerverwaltung |
|
||||||
|
| Teamleiter | schreibend auf Mitarbeiter/Teams/Projekte/Aufgaben und eigenen Urlaub; lesend auf Urlaub anderer |
|
||||||
|
| Mitarbeiter | schreibend nur auf eigenen Urlaub (mit Vertreterpflicht) und den Status eigener zugewiesener Aufgaben; sonst lesend |
|
||||||
|
| Beobachter | nur lesend, sieht ausschließlich Zeitplan/Auslastung/Urlaub |
|
||||||
|
|
||||||
|
**Passwort-Vergabe:** Neue Benutzerkonten (auch der Start-Admin) werden ohne
|
||||||
|
Passwort angelegt. Beim allerersten Login wird das eingegebene Passwort als
|
||||||
|
neues Passwort übernommen.
|
||||||
|
|
||||||
|
**Urlaubsvertretung:** Jeder Urlaubseintrag benötigt einen Vertreter aus der
|
||||||
|
Mitarbeiterliste. Eine Person darf nicht gleichzeitig (überlappender Zeitraum)
|
||||||
|
als Vertreter für zwei Urlaube *innerhalb derselben Berechtigungsrolle* der
|
||||||
|
jeweils antragstellenden Person eingetragen sein.
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
```
|
```
|
||||||
ressourcenplanung/
|
ressourcenplanung/
|
||||||
├── server/ Node/Express + SQLite (better-sqlite3)
|
├── server/ Node/Express + SQLite (better-sqlite3)
|
||||||
│ ├── schema.sql Tabellen & Beziehungen
|
│ ├── schema.sql Tabellen & Beziehungen (inkl. users, sessions, vacations)
|
||||||
│ ├── db.js DB-Init + Beispieldaten
|
│ ├── db.js DB-Init, Beispieldaten, Start-Admin-Bootstrap
|
||||||
│ └── index.js REST-API (CRUD + Auslastungsberechnung)
|
│ ├── auth.js Passwort-Hashing, Sessions, requireAuth/requireRole
|
||||||
|
│ ├── index.js REST-API (CRUD + Auslastungsberechnung)
|
||||||
|
│ └── scripts/
|
||||||
|
│ ├── reset-admin.js Admin-Zugang anlegen/zurücksetzen (CLI)
|
||||||
|
│ └── migrate-team-accounts.js Login-Konten für bestehende Mitarbeiter anlegen (CLI)
|
||||||
└── client/ Vue 3 + Vite
|
└── client/ Vue 3 + Vite
|
||||||
└── src/
|
└── src/
|
||||||
├── App.vue Tab-Navigation
|
├── App.vue Login-Gate + rollenbasierte Tab-Navigation
|
||||||
|
├── LoginPanel.vue Login (Erstanmeldung vergibt Passwort)
|
||||||
├── SchedulePanel.vue Gantt-Zeitplan
|
├── SchedulePanel.vue Gantt-Zeitplan
|
||||||
├── UtilizationPanel.vue Auslastungs-Heatmap
|
├── UtilizationPanel.vue Auslastungs-Heatmap (urlaubsbereinigt)
|
||||||
|
├── VacationPanel.vue Urlaubsplanung inkl. Vertreterregelung
|
||||||
├── ProjectsPanel.vue Projekte/Aufgaben inkl. Zuweisungen
|
├── ProjectsPanel.vue Projekte/Aufgaben inkl. Zuweisungen
|
||||||
├── EmployeesPanel.vue Mitarbeiter
|
├── EmployeesPanel.vue Mitarbeiter
|
||||||
├── TeamsPanel.vue Teams (n:m-Mitgliedschaft)
|
├── TeamsPanel.vue Teams (n:m-Mitgliedschaft)
|
||||||
|
├── UsersPanel.vue Benutzerverwaltung (nur Admin)
|
||||||
├── store.js Reaktiver State + CRUD gegen die API
|
├── store.js Reaktiver State + CRUD gegen die API
|
||||||
├── api.js API-Client
|
├── api.js API-Client + Token-Handling
|
||||||
└── helpers.js Datum-/Gantt-/Farbberechnung
|
└── helpers.js Datum-/Gantt-/Farbberechnung
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,6 +65,14 @@ npm install
|
|||||||
npm run dev # startet auf http://localhost:8080
|
npm run dev # startet auf http://localhost:8080
|
||||||
```
|
```
|
||||||
Beim ersten Start wird `server/data/data.db` angelegt und mit Beispieldaten befüllt.
|
Beim ersten Start wird `server/data/data.db` angelegt und mit Beispieldaten befüllt.
|
||||||
|
Mit der Umgebungsvariable `ADMIN_EMAIL` (muss zur `email` eines bestehenden
|
||||||
|
Mitarbeiters passen) wird zusätzlich automatisch ein Start-Admin-Konto angelegt,
|
||||||
|
z. B.:
|
||||||
|
```bash
|
||||||
|
ADMIN_EMAIL=tom.fischer@example.com npm run dev
|
||||||
|
```
|
||||||
|
Ohne `ADMIN_EMAIL` wird kein Admin-Konto erzeugt — dann per
|
||||||
|
`node scripts/reset-admin.js --employee-id <id>` nachholen (siehe unten).
|
||||||
|
|
||||||
**2) Frontend**
|
**2) Frontend**
|
||||||
```bash
|
```bash
|
||||||
@@ -49,15 +87,69 @@ per Proxy an das Backend weiter.
|
|||||||
## Deployment (Docker)
|
## Deployment (Docker)
|
||||||
|
|
||||||
Die App läuft als ein Container: Das Backend serviert die REST-API und das
|
Die App läuft als ein Container: Das Backend serviert die REST-API und das
|
||||||
gebaute Frontend intern über Port 8080, nach außen gemappt auf Host-Port 8090
|
gebaute Frontend intern über Port 8080. `docker-compose.yml` ist auf den in
|
||||||
(siehe `docker-compose.yml`).
|
Coolify installierten Caddy-Proxy zugeschnitten (siehe Abschnitt
|
||||||
|
"Deployment in Coolify") — es wird kein Host-Port mehr veröffentlicht,
|
||||||
|
stattdessen hängt sich der Container über das externe Docker-Netzwerk
|
||||||
|
`coolify` in den Proxy ein und ist über die im `caddy`-Label hinterlegte
|
||||||
|
Domain erreichbar.
|
||||||
|
|
||||||
|
Für einen rein lokalen Test außerhalb von Coolify muss das Netzwerk zuerst
|
||||||
|
angelegt werden, danach ist die App (ohne Domain/TLS) nur über die
|
||||||
|
Container-IP oder testweise per Port-Override erreichbar:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker network create coolify # einmalig, falls nicht vorhanden
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach ist die App unter http://localhost:8090 erreichbar. Die SQLite-Datenbank
|
Die SQLite-Datenbank liegt im Docker-Volume `data`. `ADMIN_EMAIL` vor dem
|
||||||
liegt im Docker-Volume `data`.
|
ersten Start setzen (z. B. in einer `.env`-Datei neben `docker-compose.yml`),
|
||||||
|
damit ein Start-Admin für
|
||||||
|
einen bestehenden Mitarbeiter angelegt wird.
|
||||||
|
|
||||||
|
## Umstieg von der alten Version (ohne Login)
|
||||||
|
|
||||||
|
Bestehende Projekte, Aufgaben, Zuweisungen, Teams und Mitarbeiter bleiben beim
|
||||||
|
Update unverändert erhalten — alle neuen Tabellen (`users`, `sessions`,
|
||||||
|
`vacations`) werden vom Server additiv angelegt (`CREATE TABLE IF NOT EXISTS`).
|
||||||
|
|
||||||
|
Damit nicht jedes bestehende Teammitglied einzeln über die Benutzerverwaltung
|
||||||
|
angelegt werden muss, legt folgendes Skript für alle Mitarbeiter mit
|
||||||
|
hinterlegter E-Mail automatisch ein Login-Konto an (Rolle `mitarbeiter`,
|
||||||
|
Passwort wird wie gewohnt beim ersten Login vergeben):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# lokal
|
||||||
|
node server/scripts/migrate-team-accounts.js
|
||||||
|
|
||||||
|
# im laufenden Container (Docker/Coolify)
|
||||||
|
docker exec <container> node scripts/migrate-team-accounts.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Mitarbeiter ohne hinterlegte E-Mail werden übersprungen (Ausgabe zeigt wer) und
|
||||||
|
müssen die E-Mail zunächst im Mitarbeiter-Tab nachtragen. Mit `--role
|
||||||
|
teamleiter` (oder `admin`/`beobachter`) lässt sich eine andere Standardrolle als
|
||||||
|
`mitarbeiter` vergeben; einzelne Rollen danach jederzeit über die
|
||||||
|
Benutzerverwaltung anpassen. Empfohlene Reihenfolge beim Umstieg: zuerst
|
||||||
|
`ADMIN_EMAIL` setzen bzw. `reset-admin.js` für den ersten Admin ausführen, dann
|
||||||
|
`migrate-team-accounts.js` für den Rest des Teams.
|
||||||
|
|
||||||
|
## Admin-Zugang anlegen/zurücksetzen
|
||||||
|
|
||||||
|
Falls kein `ADMIN_EMAIL` gesetzt war oder das Passwort verloren ging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# lokal
|
||||||
|
node server/scripts/reset-admin.js --employee-id 3
|
||||||
|
|
||||||
|
# im laufenden Container (Docker/Coolify)
|
||||||
|
docker exec <container> node scripts/reset-admin.js --employee-id 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `--password` wird das Konto (neu) angelegt/verknüpft; das Passwort wird
|
||||||
|
beim nächsten Login vergeben. Mit `--password` wird sofort ein Passwort gesetzt
|
||||||
|
(Notfall-Wiederherstellung).
|
||||||
|
|
||||||
## Deployment in Coolify
|
## Deployment in Coolify
|
||||||
|
|
||||||
@@ -67,20 +159,28 @@ liegt im Docker-Volume `data`.
|
|||||||
2. **Build-Pack**: **Docker Compose** wählen. Das mitgelieferte
|
2. **Build-Pack**: **Docker Compose** wählen. Das mitgelieferte
|
||||||
`docker-compose.yml` baut das `Dockerfile` (Multi-Stage: Vue-Build +
|
`docker-compose.yml` baut das `Dockerfile` (Multi-Stage: Vue-Build +
|
||||||
Node-Server) und startet einen einzelnen Container.
|
Node-Server) und startet einen einzelnen Container.
|
||||||
3. **Port**: Der Container lauscht intern auf Port `8080`, im
|
3. **Routing über den Caddy-Proxy**: Der Container veröffentlicht keinen
|
||||||
`docker-compose.yml` auf Host-Port `8090` gemappt (Port 8080 war auf dem
|
Host-Port mehr, sondern hängt sich über das externe Netzwerk `coolify`
|
||||||
Host bereits durch eine andere Ressource belegt →
|
(von Coolify automatisch angelegt) in den von Coolify verwalteten
|
||||||
`driver failed programming external connectivity ... port is already
|
Caddy-Proxy ein. Die Labels in `docker-compose.yml`
|
||||||
allocated`). In Coolify unter "Domains"/der Port-Konfiguration der
|
(`caddy: ressourceksr.bbundco.de` / `caddy.reverse_proxy:
|
||||||
Ressource **muss der Host-Port (8090) explizit eingetragen werden** —
|
"{{upstreams 8080}}"`) sorgen dafür, dass Caddy die Domain automatisch
|
||||||
das passiert nicht automatisch. Falls auch 8090 belegt sein sollte, in
|
auf Container-Port `8080` routet und ein TLS-Zertifikat besorgt. Damit
|
||||||
`docker-compose.yml` einen anderen freien Host-Port wählen (z. B.
|
das funktioniert, muss die Domain per DNS (A/AAAA-Record) auf den
|
||||||
`"8091:8080"`) und die Coolify-Konfiguration entsprechend nachziehen.
|
Coolify-Host zeigen; einen zusätzlichen Domain-/Port-Eintrag in der
|
||||||
|
Coolify-UI braucht es für dieses Setup nicht mehr. Soll die App unter
|
||||||
|
einer anderen Domain laufen, das `caddy`-Label entsprechend anpassen.
|
||||||
4. **Persistenz**: Das Docker-Volume `data` sichert `server/data/data.db`
|
4. **Persistenz**: Das Docker-Volume `data` sichert `server/data/data.db`
|
||||||
(SQLite). Bei Redeploys bleiben die Daten erhalten, solange das Volume
|
(SQLite). Bei Redeploys bleiben die Daten erhalten, solange das Volume
|
||||||
nicht gelöscht wird. Coolifys eingebaute Volume-Backup-Funktion kann
|
nicht gelöscht wird. Coolifys eingebaute Volume-Backup-Funktion kann
|
||||||
dafür verwendet werden.
|
dafür verwendet werden.
|
||||||
5. **Healthcheck**: Das Image bringt einen `HEALTHCHECK` gegen `/health`
|
5. **Healthcheck**: Das Image bringt einen `HEALTHCHECK` gegen `/health`
|
||||||
mit; Coolify nutzt ihn automatisch, um den Container-Status anzuzeigen.
|
mit; Coolify nutzt ihn automatisch, um den Container-Status anzuzeigen.
|
||||||
6. **Deploy** klicken. Beim allerersten Start legt der Server automatisch
|
6. **Umgebungsvariable**: `ADMIN_EMAIL` in Coolify unter den
|
||||||
|
Environment-Variablen der Ressource setzen (E-Mail eines bestehenden
|
||||||
|
Mitarbeiters) — damit wird beim ersten Start automatisch ein Start-Admin
|
||||||
|
angelegt, der sein Passwort beim ersten Login selbst vergibt. Ohne
|
||||||
|
`ADMIN_EMAIL` bleibt die App ohne Admin-Konto; dann `reset-admin.js` per
|
||||||
|
`docker exec` nachholen (siehe oben).
|
||||||
|
7. **Deploy** klicken. Beim allerersten Start legt der Server automatisch
|
||||||
`data.db` an und befüllt sie mit Beispieldaten.
|
`data.db` an und befüllt sie mit Beispieldaten.
|
||||||
|
|||||||
+57
-32
@@ -1,47 +1,72 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { store } from './store.js';
|
import { store } from './store.js';
|
||||||
|
import LoginPanel from './LoginPanel.vue';
|
||||||
import SchedulePanel from './SchedulePanel.vue';
|
import SchedulePanel from './SchedulePanel.vue';
|
||||||
import UtilizationPanel from './UtilizationPanel.vue';
|
import UtilizationPanel from './UtilizationPanel.vue';
|
||||||
|
import VacationPanel from './VacationPanel.vue';
|
||||||
import ProjectsPanel from './ProjectsPanel.vue';
|
import ProjectsPanel from './ProjectsPanel.vue';
|
||||||
import EmployeesPanel from './EmployeesPanel.vue';
|
import EmployeesPanel from './EmployeesPanel.vue';
|
||||||
import TeamsPanel from './TeamsPanel.vue';
|
import TeamsPanel from './TeamsPanel.vue';
|
||||||
|
import UsersPanel from './UsersPanel.vue';
|
||||||
|
|
||||||
const tabs = [
|
const ALL_TABS = [
|
||||||
{ id: 'schedule', label: 'Zeitplan' },
|
{ id: 'schedule', label: 'Zeitplan', roles: ['admin', 'teamleiter', 'mitarbeiter', 'beobachter'] },
|
||||||
{ id: 'utilization', label: 'Auslastung' },
|
{ id: 'utilization', label: 'Auslastung', roles: ['admin', 'teamleiter', 'mitarbeiter', 'beobachter'] },
|
||||||
{ id: 'projects', label: 'Projekte' },
|
{ id: 'vacation', label: 'Urlaub', roles: ['admin', 'teamleiter', 'mitarbeiter', 'beobachter'] },
|
||||||
{ id: 'employees', label: 'Mitarbeiter' },
|
{ id: 'projects', label: 'Projekte', roles: ['admin', 'teamleiter', 'mitarbeiter'] },
|
||||||
{ id: 'teams', label: 'Teams' },
|
{ id: 'employees', label: 'Mitarbeiter', roles: ['admin', 'teamleiter', 'mitarbeiter'] },
|
||||||
|
{ id: 'teams', label: 'Teams', roles: ['admin', 'teamleiter', 'mitarbeiter'] },
|
||||||
|
{ id: 'users', label: 'Benutzer', roles: ['admin'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
const role = store.session.user?.role;
|
||||||
|
return ALL_TABS.filter((t) => t.roles.includes(role));
|
||||||
|
});
|
||||||
const activeTab = ref('schedule');
|
const activeTab = ref('schedule');
|
||||||
|
|
||||||
onMounted(() => store.loadAll());
|
onMounted(async () => {
|
||||||
|
await store.restoreSession();
|
||||||
|
if (store.session.user) await store.loadAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await store.logout();
|
||||||
|
activeTab.value = 'schedule';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="topbar">
|
<LoginPanel v-if="!store.session.user" />
|
||||||
<h1>Ressourcenplanung</h1>
|
<template v-else>
|
||||||
<nav class="tabs">
|
<header class="topbar">
|
||||||
<button
|
<h1>Ressourcenplanung</h1>
|
||||||
v-for="tab in tabs"
|
<nav class="tabs">
|
||||||
:key="tab.id"
|
<button
|
||||||
:class="{ active: activeTab === tab.id }"
|
v-for="tab in tabs"
|
||||||
@click="activeTab = tab.id"
|
:key="tab.id"
|
||||||
>
|
:class="{ active: activeTab === tab.id }"
|
||||||
{{ tab.label }}
|
@click="activeTab = tab.id"
|
||||||
</button>
|
>
|
||||||
</nav>
|
{{ tab.label }}
|
||||||
</header>
|
</button>
|
||||||
<main>
|
</nav>
|
||||||
<p v-if="store.loading" class="muted">Lade Daten…</p>
|
<span class="muted">{{ store.session.user.email }} ({{ store.session.user.role }})</span>
|
||||||
<p v-if="store.error" class="muted" style="color: #ef4444">{{ store.error }}</p>
|
<button class="btn secondary" @click="logout">Abmelden</button>
|
||||||
<template v-else>
|
</header>
|
||||||
<SchedulePanel v-if="activeTab === 'schedule'" />
|
<main>
|
||||||
<UtilizationPanel v-if="activeTab === 'utilization'" />
|
<p v-if="store.loading" class="muted">Lade Daten…</p>
|
||||||
<ProjectsPanel v-if="activeTab === 'projects'" />
|
<p v-if="store.error" class="muted" style="color: #ef4444">{{ store.error }}</p>
|
||||||
<EmployeesPanel v-if="activeTab === 'employees'" />
|
<template v-else>
|
||||||
<TeamsPanel v-if="activeTab === 'teams'" />
|
<SchedulePanel v-if="activeTab === 'schedule'" />
|
||||||
</template>
|
<UtilizationPanel v-if="activeTab === 'utilization'" />
|
||||||
</main>
|
<VacationPanel v-if="activeTab === 'vacation'" />
|
||||||
|
<ProjectsPanel v-if="activeTab === 'projects'" />
|
||||||
|
<EmployeesPanel v-if="activeTab === 'employees'" />
|
||||||
|
<TeamsPanel v-if="activeTab === 'teams'" />
|
||||||
|
<UsersPanel v-if="activeTab === 'users'" />
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { store } from './store.js';
|
import { store } from './store.js';
|
||||||
|
|
||||||
|
const canManage = computed(() => ['admin', 'teamleiter'].includes(store.session.user?.role));
|
||||||
|
|
||||||
const newEmployee = reactive({ first_name: '', last_name: '', email: '', role: '', weekly_capacity_hours: 40 });
|
const newEmployee = reactive({ first_name: '', last_name: '', email: '', role: '', weekly_capacity_hours: 40 });
|
||||||
|
|
||||||
async function addEmployee() {
|
async function addEmployee() {
|
||||||
@@ -36,7 +38,7 @@ async function saveEdit(id) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="panel-section">
|
<div class="panel-section" v-if="canManage">
|
||||||
<h2>Neuer Mitarbeiter</h2>
|
<h2>Neuer Mitarbeiter</h2>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input v-model="newEmployee.first_name" placeholder="Vorname" />
|
<input v-model="newEmployee.first_name" placeholder="Vorname" />
|
||||||
@@ -63,7 +65,7 @@ async function saveEdit(id) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="e in store.employees" :key="e.id">
|
<tr v-for="e in store.employees" :key="e.id">
|
||||||
<template v-if="editingId === e.id">
|
<template v-if="editingId === e.id && canManage">
|
||||||
<td><input v-model="editForm.first_name" style="width: 100%" /></td>
|
<td><input v-model="editForm.first_name" style="width: 100%" /></td>
|
||||||
<td><input v-model="editForm.last_name" style="width: 100%" /></td>
|
<td><input v-model="editForm.last_name" style="width: 100%" /></td>
|
||||||
<td><input v-model="editForm.email" style="width: 100%" /></td>
|
<td><input v-model="editForm.email" style="width: 100%" /></td>
|
||||||
@@ -80,10 +82,11 @@ async function saveEdit(id) {
|
|||||||
<td>{{ e.email }}</td>
|
<td>{{ e.email }}</td>
|
||||||
<td>{{ e.role }}</td>
|
<td>{{ e.role }}</td>
|
||||||
<td>{{ e.weekly_capacity_hours }}</td>
|
<td>{{ e.weekly_capacity_hours }}</td>
|
||||||
<td>
|
<td v-if="canManage">
|
||||||
<button class="btn secondary" @click="startEdit(e)">Bearbeiten</button>
|
<button class="btn secondary" @click="startEdit(e)">Bearbeiten</button>
|
||||||
<button class="btn danger" @click="store.deleteEmployee(e.id)">Löschen</button>
|
<button class="btn danger" @click="store.deleteEmployee(e.id)">Löschen</button>
|
||||||
</td>
|
</td>
|
||||||
|
<td v-else></td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import { store } from './store.js';
|
||||||
|
|
||||||
|
const form = reactive({ email: '', password: '' });
|
||||||
|
const error = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!form.email || !form.password) return;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await store.login(form.email, form.password);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-section" style="max-width: 360px; margin: 64px auto">
|
||||||
|
<h2>Anmelden</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Neue Konten haben noch kein Passwort: das beim ersten Login eingegebene Passwort wird übernommen.
|
||||||
|
</p>
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<div class="form-row" style="flex-direction: column; align-items: stretch">
|
||||||
|
<input v-model="form.email" type="email" placeholder="E-Mail" autocomplete="username" />
|
||||||
|
<input v-model="form.password" type="password" placeholder="Passwort" autocomplete="current-password" />
|
||||||
|
<button class="btn" type="submit" :disabled="loading">{{ loading ? 'Anmelden…' : 'Anmelden' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p v-if="error" class="muted" style="color: #ef4444">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import { store } from './store.js';
|
import { store } from './store.js';
|
||||||
import { formatDate, fullName } from './helpers.js';
|
import { formatDate, fullName } from './helpers.js';
|
||||||
|
|
||||||
|
const canManage = computed(() => ['admin', 'teamleiter'].includes(store.session.user?.role));
|
||||||
|
|
||||||
|
function isAssignedToMe(task) {
|
||||||
|
return task.assignments.some((a) => a.employee_id === store.session.user?.employee_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusEditable(task) {
|
||||||
|
return canManage.value || isAssignedToMe(task);
|
||||||
|
}
|
||||||
|
|
||||||
const newProject = reactive({ name: '', description: '', start_date: '', end_date: '', color: '#4f46e5' });
|
const newProject = reactive({ name: '', description: '', start_date: '', end_date: '', color: '#4f46e5' });
|
||||||
const newTask = reactive({});
|
const newTask = reactive({});
|
||||||
const newAssignment = reactive({});
|
const newAssignment = reactive({});
|
||||||
@@ -60,7 +70,7 @@ async function duplicateTask(projectId, taskId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="panel-section">
|
<div class="panel-section" v-if="canManage">
|
||||||
<h2>Neues Projekt</h2>
|
<h2>Neues Projekt</h2>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input v-model="newProject.name" placeholder="Projektname" />
|
<input v-model="newProject.name" placeholder="Projektname" />
|
||||||
@@ -75,7 +85,7 @@ async function duplicateTask(projectId, taskId) {
|
|||||||
<div class="panel-section" v-for="project in store.projects" :key="project.id">
|
<div class="panel-section" v-for="project in store.projects" :key="project.id">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||||
<h2 :style="{ color: project.color }">{{ project.name }}</h2>
|
<h2 :style="{ color: project.color }">{{ project.name }}</h2>
|
||||||
<button class="btn secondary" @click="expanded[project.id] = !expanded[project.id]">
|
<button v-if="canManage" class="btn secondary" @click="expanded[project.id] = !expanded[project.id]">
|
||||||
{{ expanded[project.id] ? 'Zuklappen' : 'Aufgaben verwalten' }}
|
{{ expanded[project.id] ? 'Zuklappen' : 'Aufgaben verwalten' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,14 +108,17 @@ async function duplicateTask(projectId, taskId) {
|
|||||||
<td>{{ formatDate(task.start_date) }}</td>
|
<td>{{ formatDate(task.start_date) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
v-if="canManage"
|
||||||
type="date"
|
type="date"
|
||||||
:value="task.end_date"
|
:value="task.end_date"
|
||||||
@change="store.updateTask(project.id, task.id, { end_date: $event.target.value })"
|
@change="store.updateTask(project.id, task.id, { end_date: $event.target.value })"
|
||||||
/>
|
/>
|
||||||
|
<span v-else>{{ formatDate(task.end_date) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ task.estimated_hours }}</td>
|
<td>{{ task.estimated_hours }}</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
|
v-if="statusEditable(task)"
|
||||||
:value="task.status"
|
:value="task.status"
|
||||||
@change="store.updateTask(project.id, task.id, { status: $event.target.value })"
|
@change="store.updateTask(project.id, task.id, { status: $event.target.value })"
|
||||||
>
|
>
|
||||||
@@ -113,17 +126,19 @@ async function duplicateTask(projectId, taskId) {
|
|||||||
<option value="in Arbeit">in Arbeit</option>
|
<option value="in Arbeit">in Arbeit</option>
|
||||||
<option value="erledigt">erledigt</option>
|
<option value="erledigt">erledigt</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span v-else>{{ task.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-for="a in task.assignments" :key="a.id" class="tag">
|
<span v-for="a in task.assignments" :key="a.id" class="tag">
|
||||||
{{ a.employee_name }} ({{ a.allocated_hours_per_week }}h)
|
{{ a.employee_name }} ({{ a.allocated_hours_per_week }}h)
|
||||||
<button
|
<button
|
||||||
|
v-if="canManage"
|
||||||
class="tag-remove"
|
class="tag-remove"
|
||||||
title="Zuweisung entfernen"
|
title="Zuweisung entfernen"
|
||||||
@click="store.unassignEmployee(project.id, task.id, a.id)"
|
@click="store.unassignEmployee(project.id, task.id, a.id)"
|
||||||
>×</button>
|
>×</button>
|
||||||
</span>
|
</span>
|
||||||
<div class="assign-inline">
|
<div v-if="canManage" class="assign-inline">
|
||||||
<select v-model="assignmentForm(task.id).employee_id">
|
<select v-model="assignmentForm(task.id).employee_id">
|
||||||
<option value="" disabled>Mitarbeiter wählen</option>
|
<option value="" disabled>Mitarbeiter wählen</option>
|
||||||
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ fullName(e) }}</option>
|
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ fullName(e) }}</option>
|
||||||
@@ -136,7 +151,7 @@ async function duplicateTask(projectId, taskId) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<template v-if="expanded[project.id]">
|
<template v-if="expanded[project.id] && canManage">
|
||||||
<h3>Neue Aufgabe</h3>
|
<h3>Neue Aufgabe</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input v-model="taskForm(project.id).name" placeholder="Aufgabenname" />
|
<input v-model="taskForm(project.id).name" placeholder="Aufgabenname" />
|
||||||
@@ -167,7 +182,7 @@ async function duplicateTask(projectId, taskId) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div style="margin-top: 12px">
|
<div v-if="canManage" style="margin-top: 12px">
|
||||||
<button class="btn danger" @click="store.deleteProject(project.id)">Projekt löschen</button>
|
<button class="btn danger" @click="store.deleteProject(project.id)">Projekt löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import { store } from './store.js';
|
import { store } from './store.js';
|
||||||
import { fullName } from './helpers.js';
|
import { fullName } from './helpers.js';
|
||||||
|
|
||||||
|
const canManage = computed(() => ['admin', 'teamleiter'].includes(store.session.user?.role));
|
||||||
|
|
||||||
const newTeam = reactive({ name: '', description: '' });
|
const newTeam = reactive({ name: '', description: '' });
|
||||||
const newMember = reactive({});
|
const newMember = reactive({});
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ async function addMember(teamId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="panel-section">
|
<div class="panel-section" v-if="canManage">
|
||||||
<h2>Neues Team</h2>
|
<h2>Neues Team</h2>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input v-model="newTeam.name" placeholder="Teamname" />
|
<input v-model="newTeam.name" placeholder="Teamname" />
|
||||||
@@ -41,11 +43,11 @@ async function addMember(teamId) {
|
|||||||
|
|
||||||
<span v-for="m in team.members" :key="m.id" class="tag">
|
<span v-for="m in team.members" :key="m.id" class="tag">
|
||||||
{{ fullName(m) }}
|
{{ fullName(m) }}
|
||||||
<a href="#" @click.prevent="store.removeTeamMember(team.id, m.id)" style="margin-left: 4px">×</a>
|
<a v-if="canManage" href="#" @click.prevent="store.removeTeamMember(team.id, m.id)" style="margin-left: 4px">×</a>
|
||||||
</span>
|
</span>
|
||||||
<p v-if="team.members.length === 0" class="muted">Keine Mitglieder.</p>
|
<p v-if="team.members.length === 0" class="muted">Keine Mitglieder.</p>
|
||||||
|
|
||||||
<div class="form-row">
|
<div v-if="canManage" class="form-row">
|
||||||
<select v-model="memberForm(team.id)[team.id]">
|
<select v-model="memberForm(team.id)[team.id]">
|
||||||
<option value="" disabled>Mitarbeiter hinzufügen</option>
|
<option value="" disabled>Mitarbeiter hinzufügen</option>
|
||||||
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ fullName(e) }}</option>
|
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ fullName(e) }}</option>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { store } from './store.js';
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'teamleiter', label: 'Teamleiter' },
|
||||||
|
{ value: 'mitarbeiter', label: 'Mitarbeiter' },
|
||||||
|
{ value: 'beobachter', label: 'Beobachter' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableEmployees = computed(() =>
|
||||||
|
store.employees.filter((e) => !store.users.some((u) => u.employee_id === e.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const newUser = reactive({ employee_id: '', email: '', role: 'mitarbeiter' });
|
||||||
|
const createError = ref(null);
|
||||||
|
|
||||||
|
function onEmployeeChange() {
|
||||||
|
const employee = store.employees.find((e) => e.id === Number(newUser.employee_id));
|
||||||
|
if (employee?.email) newUser.email = employee.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUser() {
|
||||||
|
createError.value = null;
|
||||||
|
if (!newUser.email || !newUser.role) return;
|
||||||
|
try {
|
||||||
|
await store.createUser({ ...newUser, employee_id: newUser.employee_id || null });
|
||||||
|
Object.assign(newUser, { employee_id: '', email: '', role: 'mitarbeiter' });
|
||||||
|
} catch (e) {
|
||||||
|
createError.value = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(id) {
|
||||||
|
await store.updateUser(id, { reset_password: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-section">
|
||||||
|
<h2>Neuer Benutzer</h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<select v-model.number="newUser.employee_id" @change="onEmployeeChange">
|
||||||
|
<option value="">(kein Mitarbeiterbezug)</option>
|
||||||
|
<option v-for="e in availableEmployees" :key="e.id" :value="e.id">{{ e.first_name }} {{ e.last_name }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="newUser.email" placeholder="E-Mail" />
|
||||||
|
<select v-model="newUser.role">
|
||||||
|
<option v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" @click="addUser">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Das Passwort wird von der Person selbst beim ersten Login vergeben.</p>
|
||||||
|
<p v-if="createError" class="muted" style="color: #ef4444">{{ createError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<h2>Benutzer</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mitarbeiter</th>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in store.users" :key="u.id">
|
||||||
|
<td>{{ u.employee_name || '—' }}</td>
|
||||||
|
<td>{{ u.email }}</td>
|
||||||
|
<td>
|
||||||
|
<select :value="u.role" @change="store.updateUser(u.id, { role: $event.target.value })">
|
||||||
|
<option v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn secondary" @click="resetPassword(u.id)">Passwort zurücksetzen</button>
|
||||||
|
<button class="btn danger" @click="store.deleteUser(u.id)">Löschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="store.users.length === 0" class="muted">Noch keine Benutzer angelegt.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -30,10 +30,10 @@ function percent(hours, capacity) {
|
|||||||
<td v-for="(hours, idx) in employee.hours" :key="idx">
|
<td v-for="(hours, idx) in employee.hours" :key="idx">
|
||||||
<div
|
<div
|
||||||
class="heatmap-cell"
|
class="heatmap-cell"
|
||||||
:style="{ background: utilizationColor(percent(hours, employee.weekly_capacity_hours)) }"
|
:style="{ background: utilizationColor(percent(hours, employee.capacity[idx])) }"
|
||||||
:title="`${hours}h von ${employee.weekly_capacity_hours}h`"
|
:title="`${hours}h von ${employee.capacity[idx]}h (Urlaub bereits abgezogen, nominal ${employee.weekly_capacity_hours}h)`"
|
||||||
>
|
>
|
||||||
{{ percent(hours, employee.weekly_capacity_hours) }}%
|
{{ percent(hours, employee.capacity[idx]) }}%
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { store } from './store.js';
|
||||||
|
import { formatDate } from './helpers.js';
|
||||||
|
|
||||||
|
const role = computed(() => store.session.user?.role);
|
||||||
|
const isManager = computed(() => role.value === 'admin' || role.value === 'teamleiter');
|
||||||
|
const ownEmployeeId = computed(() => store.session.user?.employee_id);
|
||||||
|
|
||||||
|
const newVacation = reactive({
|
||||||
|
employee_id: ownEmployeeId.value || '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
substitute_employee_id: '',
|
||||||
|
note: '',
|
||||||
|
});
|
||||||
|
const createError = ref(null);
|
||||||
|
|
||||||
|
function substitutesFor(employeeId) {
|
||||||
|
return store.employees.filter((e) => e.id !== Number(employeeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addVacation() {
|
||||||
|
createError.value = null;
|
||||||
|
if (!newVacation.start_date || !newVacation.end_date || !newVacation.substitute_employee_id) return;
|
||||||
|
try {
|
||||||
|
await store.createVacation({ ...newVacation });
|
||||||
|
Object.assign(newVacation, {
|
||||||
|
employee_id: ownEmployeeId.value || '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
substitute_employee_id: '',
|
||||||
|
note: '',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
createError.value = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEdit(vacation) {
|
||||||
|
return isManager.value || vacation.employee_id === ownEmployeeId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editingId = ref(null);
|
||||||
|
const editForm = reactive({ start_date: '', end_date: '', substitute_employee_id: '', note: '' });
|
||||||
|
const editError = ref(null);
|
||||||
|
|
||||||
|
function startEdit(vacation) {
|
||||||
|
editingId.value = vacation.id;
|
||||||
|
editError.value = null;
|
||||||
|
Object.assign(editForm, {
|
||||||
|
start_date: vacation.start_date,
|
||||||
|
end_date: vacation.end_date,
|
||||||
|
substitute_employee_id: vacation.substitute_employee_id,
|
||||||
|
note: vacation.note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(id) {
|
||||||
|
editError.value = null;
|
||||||
|
try {
|
||||||
|
await store.updateVacation(id, { ...editForm });
|
||||||
|
editingId.value = null;
|
||||||
|
} catch (e) {
|
||||||
|
editError.value = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-section">
|
||||||
|
<h2>Neuer Urlaubseintrag</h2>
|
||||||
|
<div class="form-row">
|
||||||
|
<select v-if="isManager" v-model.number="newVacation.employee_id">
|
||||||
|
<option value="" disabled>Mitarbeiter wählen</option>
|
||||||
|
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ e.first_name }} {{ e.last_name }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="newVacation.start_date" type="date" />
|
||||||
|
<input v-model="newVacation.end_date" type="date" />
|
||||||
|
<select v-model.number="newVacation.substitute_employee_id">
|
||||||
|
<option value="" disabled>Vertreter wählen</option>
|
||||||
|
<option v-for="e in substitutesFor(newVacation.employee_id)" :key="e.id" :value="e.id">
|
||||||
|
{{ e.first_name }} {{ e.last_name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="newVacation.note" placeholder="Notiz (optional)" />
|
||||||
|
<button class="btn" @click="addVacation">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="createError" class="muted" style="color: #ef4444">{{ createError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<h2>Urlaubsplanung</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mitarbeiter</th>
|
||||||
|
<th>Zeitraum</th>
|
||||||
|
<th>Vertreter</th>
|
||||||
|
<th>Notiz</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="v in store.vacations" :key="v.id">
|
||||||
|
<template v-if="editingId === v.id">
|
||||||
|
<td>{{ v.employee_name }}</td>
|
||||||
|
<td>
|
||||||
|
<input v-model="editForm.start_date" type="date" style="width: 130px" />
|
||||||
|
<input v-model="editForm.end_date" type="date" style="width: 130px" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select v-model.number="editForm.substitute_employee_id">
|
||||||
|
<option v-for="e in substitutesFor(v.employee_id)" :key="e.id" :value="e.id">
|
||||||
|
{{ e.first_name }} {{ e.last_name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input v-model="editForm.note" style="width: 100%" /></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn" @click="saveEdit(v.id)">Speichern</button>
|
||||||
|
<button class="btn secondary" @click="cancelEdit">Abbrechen</button>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<td>{{ v.employee_name }}</td>
|
||||||
|
<td>{{ formatDate(v.start_date) }} – {{ formatDate(v.end_date) }}</td>
|
||||||
|
<td>{{ v.substitute_name }}</td>
|
||||||
|
<td>{{ v.note }}</td>
|
||||||
|
<td v-if="canEdit(v)">
|
||||||
|
<button class="btn secondary" @click="startEdit(v)">Bearbeiten</button>
|
||||||
|
<button class="btn danger" @click="store.deleteVacation(v.id)">Löschen</button>
|
||||||
|
</td>
|
||||||
|
<td v-else></td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="editError" class="muted" style="color: #ef4444">{{ editError }}</p>
|
||||||
|
<p v-if="store.vacations.length === 0" class="muted">Noch keine Urlaubseinträge vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+37
-2
@@ -1,18 +1,53 @@
|
|||||||
|
let authToken = localStorage.getItem('authToken') || null;
|
||||||
|
|
||||||
|
export function setAuthToken(token) {
|
||||||
|
authToken = token;
|
||||||
|
if (token) localStorage.setItem('authToken', token);
|
||||||
|
else localStorage.removeItem('authToken');
|
||||||
|
}
|
||||||
|
|
||||||
async function request(method, url, body) {
|
async function request(method, url, body) {
|
||||||
|
const headers = {};
|
||||||
|
if (body) headers['Content-Type'] = 'application/json';
|
||||||
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
headers,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
throw new Error(`${method} ${url} fehlgeschlagen: ${res.status} ${text}`);
|
let error;
|
||||||
|
try {
|
||||||
|
error = JSON.parse(text).error;
|
||||||
|
} catch {
|
||||||
|
error = text;
|
||||||
|
}
|
||||||
|
throw new Error(error || `${method} ${url} fehlgeschlagen: ${res.status}`);
|
||||||
}
|
}
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
return text ? JSON.parse(text) : null;
|
return text ? JSON.parse(text) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
// auth
|
||||||
|
login: (email, password) => request('POST', '/api/auth/login', { email, password }),
|
||||||
|
logout: () => request('POST', '/api/auth/logout'),
|
||||||
|
me: () => request('GET', '/api/auth/me'),
|
||||||
|
|
||||||
|
// vacations
|
||||||
|
getVacations: () => request('GET', '/api/vacations'),
|
||||||
|
createVacation: (data) => request('POST', '/api/vacations', data),
|
||||||
|
updateVacation: (id, data) => request('PUT', `/api/vacations/${id}`, data),
|
||||||
|
deleteVacation: (id) => request('DELETE', `/api/vacations/${id}`),
|
||||||
|
|
||||||
|
// users
|
||||||
|
getUsers: () => request('GET', '/api/users'),
|
||||||
|
createUser: (data) => request('POST', '/api/users', data),
|
||||||
|
updateUser: (id, data) => request('PUT', `/api/users/${id}`, data),
|
||||||
|
deleteUser: (id) => request('DELETE', `/api/users/${id}`),
|
||||||
|
|
||||||
// employees
|
// employees
|
||||||
getEmployees: () => request('GET', '/api/employees'),
|
getEmployees: () => request('GET', '/api/employees'),
|
||||||
createEmployee: (data) => request('POST', '/api/employees', data),
|
createEmployee: (data) => request('POST', '/api/employees', data),
|
||||||
|
|||||||
+84
-2
@@ -1,27 +1,78 @@
|
|||||||
import { reactive } from 'vue';
|
import { reactive } from 'vue';
|
||||||
import { api } from './api.js';
|
import { api, setAuthToken } from './api.js';
|
||||||
import { fullName } from './helpers.js';
|
import { fullName } from './helpers.js';
|
||||||
|
|
||||||
|
const storedUser = localStorage.getItem('authUser');
|
||||||
|
|
||||||
export const store = reactive({
|
export const store = reactive({
|
||||||
employees: [],
|
employees: [],
|
||||||
teams: [],
|
teams: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
|
vacations: [],
|
||||||
|
users: [],
|
||||||
utilization: null,
|
utilization: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
|
session: {
|
||||||
|
token: localStorage.getItem('authToken') || null,
|
||||||
|
user: storedUser ? JSON.parse(storedUser) : null,
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreSession() {
|
||||||
|
if (!this.session.token) return;
|
||||||
|
try {
|
||||||
|
this.session.user = await api.me();
|
||||||
|
localStorage.setItem('authUser', JSON.stringify(this.session.user));
|
||||||
|
} catch {
|
||||||
|
this.logout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(email, password) {
|
||||||
|
const { token, user } = await api.login(email, password);
|
||||||
|
setAuthToken(token);
|
||||||
|
localStorage.setItem('authUser', JSON.stringify(user));
|
||||||
|
this.session.token = token;
|
||||||
|
this.session.user = user;
|
||||||
|
await this.loadAll();
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
if (this.session.token) await api.logout();
|
||||||
|
} catch {
|
||||||
|
// Sitzung ggf. schon abgelaufen, trotzdem lokal ausloggen
|
||||||
|
}
|
||||||
|
setAuthToken(null);
|
||||||
|
localStorage.removeItem('authUser');
|
||||||
|
this.session.token = null;
|
||||||
|
this.session.user = null;
|
||||||
|
this.employees = [];
|
||||||
|
this.teams = [];
|
||||||
|
this.projects = [];
|
||||||
|
this.vacations = [];
|
||||||
|
this.users = [];
|
||||||
|
this.utilization = null;
|
||||||
|
},
|
||||||
|
|
||||||
async loadAll() {
|
async loadAll() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const [employees, teams, projects] = await Promise.all([
|
const [employees, teams, projects, vacations] = await Promise.all([
|
||||||
api.getEmployees(),
|
api.getEmployees(),
|
||||||
api.getTeams(),
|
api.getTeams(),
|
||||||
api.getProjects(),
|
api.getProjects(),
|
||||||
|
api.getVacations(),
|
||||||
]);
|
]);
|
||||||
this.employees = employees;
|
this.employees = employees;
|
||||||
this.teams = teams;
|
this.teams = teams;
|
||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
|
this.vacations = vacations;
|
||||||
|
if (this.session.user?.role === 'admin') {
|
||||||
|
this.users = await api.getUsers();
|
||||||
|
}
|
||||||
await this.loadUtilization();
|
await this.loadUtilization();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
@@ -142,4 +193,35 @@ export const store = reactive({
|
|||||||
if (task) task.assignments = task.assignments.filter((a) => a.id !== assignmentId);
|
if (task) task.assignments = task.assignments.filter((a) => a.id !== assignmentId);
|
||||||
await this.loadUtilization();
|
await this.loadUtilization();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// vacations
|
||||||
|
async createVacation(data) {
|
||||||
|
this.vacations.unshift(await api.createVacation(data));
|
||||||
|
await this.loadUtilization();
|
||||||
|
},
|
||||||
|
async updateVacation(id, data) {
|
||||||
|
const updated = await api.updateVacation(id, data);
|
||||||
|
const idx = this.vacations.findIndex((v) => v.id === id);
|
||||||
|
if (idx !== -1) this.vacations[idx] = updated;
|
||||||
|
await this.loadUtilization();
|
||||||
|
},
|
||||||
|
async deleteVacation(id) {
|
||||||
|
await api.deleteVacation(id);
|
||||||
|
this.vacations = this.vacations.filter((v) => v.id !== id);
|
||||||
|
await this.loadUtilization();
|
||||||
|
},
|
||||||
|
|
||||||
|
// users
|
||||||
|
async createUser(data) {
|
||||||
|
this.users.push(await api.createUser(data));
|
||||||
|
},
|
||||||
|
async updateUser(id, data) {
|
||||||
|
const updated = await api.updateUser(id, data);
|
||||||
|
const idx = this.users.findIndex((u) => u.id === id);
|
||||||
|
if (idx !== -1) this.users[idx] = updated;
|
||||||
|
},
|
||||||
|
async deleteUser(id) {
|
||||||
|
await api.deleteUser(id);
|
||||||
|
this.users = this.users.filter((u) => u.id !== id);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-2
@@ -1,11 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
expose:
|
||||||
- "8090:8080"
|
- "8080"
|
||||||
|
environment:
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||||
volumes:
|
volumes:
|
||||||
- data:/app/server/data
|
- data:/app/server/data
|
||||||
|
networks:
|
||||||
|
- coolify
|
||||||
|
labels:
|
||||||
|
caddy: ressourceksr.bbundco.de
|
||||||
|
caddy.reverse_proxy: "{{upstreams 8080}}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
const SESSION_LIFETIME_DAYS = 30;
|
||||||
|
|
||||||
|
function hashPassword(password) {
|
||||||
|
return bcrypt.hashSync(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password, hash) {
|
||||||
|
return bcrypt.compareSync(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(userId) {
|
||||||
|
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_LIFETIME_DAYS} days'))`
|
||||||
|
).run(token, userId);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const header = req.get('authorization') || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
if (!token) return res.status(401).json({ error: 'nicht angemeldet' });
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.employee_id, u.email, u.role FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token = ? AND s.expires_at > datetime('now')`
|
||||||
|
)
|
||||||
|
.get(token);
|
||||||
|
if (!row) return res.status(401).json({ error: 'Sitzung ungültig oder abgelaufen' });
|
||||||
|
|
||||||
|
req.user = row;
|
||||||
|
req.sessionToken = token;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRole(...roles) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user || !roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'keine Berechtigung' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hashPassword, verifyPassword, createSession, requireAuth, requireRole };
|
||||||
@@ -15,6 +15,7 @@ const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
|||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
|
|
||||||
migrateEmployeeName();
|
migrateEmployeeName();
|
||||||
|
seedInitialAdmin();
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
seed();
|
seed();
|
||||||
@@ -41,6 +42,41 @@ function migrateEmployeeName() {
|
|||||||
db.exec('ALTER TABLE employees DROP COLUMN name');
|
db.exec('ALTER TABLE employees DROP COLUMN name');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legt beim allerersten Start einen Admin-Zugang für einen BESTEHENDEN Mitarbeiter an,
|
||||||
|
// dessen employees.email mit ADMIN_EMAIL übereinstimmt. Es wird bewusst kein Passwort
|
||||||
|
// gesetzt: wie bei allen über die Benutzerverwaltung angelegten Konten wird das
|
||||||
|
// Passwort erst beim allerersten Login vom Nutzer selbst vergeben (siehe /api/auth/login).
|
||||||
|
function seedInitialAdmin() {
|
||||||
|
const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||||
|
if (userCount > 0) return;
|
||||||
|
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
|
if (!adminEmail) {
|
||||||
|
console.log(
|
||||||
|
'Hinweis: ADMIN_EMAIL ist nicht gesetzt, es wurde kein Start-Admin angelegt. ' +
|
||||||
|
'Bitte ADMIN_EMAIL auf die E-Mail eines bestehenden Mitarbeiters setzen und neu starten, ' +
|
||||||
|
'oder scripts/reset-admin.js verwenden.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = db.prepare('SELECT id FROM employees WHERE email = ?').get(adminEmail);
|
||||||
|
if (!employee) {
|
||||||
|
console.log(
|
||||||
|
`Hinweis: ADMIN_EMAIL (${adminEmail}) passt zu keinem bestehenden Mitarbeiter, ` +
|
||||||
|
'es wurde kein Start-Admin angelegt.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('INSERT INTO users (employee_id, email, password_hash, role) VALUES (?, ?, NULL, ?)').run(
|
||||||
|
employee.id,
|
||||||
|
adminEmail,
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
console.log(`Start-Admin für ${adminEmail} angelegt. Das Passwort wird beim ersten Login vergeben.`);
|
||||||
|
}
|
||||||
|
|
||||||
function seed() {
|
function seed() {
|
||||||
const insertEmployee = db.prepare(
|
const insertEmployee = db.prepare(
|
||||||
'INSERT INTO employees (first_name, last_name, email, role, weekly_capacity_hours) VALUES (?, ?, ?, ?, ?)'
|
'INSERT INTO employees (first_name, last_name, email, role, weekly_capacity_hours) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
|||||||
+300
-28
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const { startBackupScheduler, runBackup } = require('./backup');
|
const { startBackupScheduler, runBackup } = require('./backup');
|
||||||
|
const { hashPassword, verifyPassword, createSession, requireAuth, requireRole } = require('./auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -9,6 +10,43 @@ app.use(express.json());
|
|||||||
|
|
||||||
const PORT = process.env.PORT || 8080;
|
const PORT = process.env.PORT || 8080;
|
||||||
|
|
||||||
|
// ---------- auth ----------
|
||||||
|
|
||||||
|
app.post('/api/auth/login', (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'email und password sind erforderlich' });
|
||||||
|
}
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'E-Mail oder Passwort falsch' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.password_hash === null) {
|
||||||
|
// Erster Login für dieses Konto: das eingegebene Passwort wird als neues Passwort übernommen.
|
||||||
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), user.id);
|
||||||
|
} else if (!verifyPassword(password, user.password_hash)) {
|
||||||
|
return res.status(401).json({ error: 'E-Mail oder Passwort falsch' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createSession(user.id);
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: { id: user.id, employee_id: user.employee_id, email: user.email, role: user.role },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api', requireAuth);
|
||||||
|
|
||||||
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE token = ?').run(req.sessionToken);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/me', (req, res) => {
|
||||||
|
res.json(req.user);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
|
|
||||||
function isoWeekKey(date) {
|
function isoWeekKey(date) {
|
||||||
@@ -45,7 +83,7 @@ app.get('/api/employees', (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM employees ORDER BY last_name, first_name').all());
|
res.json(db.prepare('SELECT * FROM employees ORDER BY last_name, first_name').all());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/employees', (req, res) => {
|
app.post('/api/employees', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { first_name, last_name, email, role, weekly_capacity_hours } = req.body;
|
const { first_name, last_name, email, role, weekly_capacity_hours } = req.body;
|
||||||
if (!first_name || !last_name) {
|
if (!first_name || !last_name) {
|
||||||
return res.status(400).json({ error: 'first_name und last_name sind erforderlich' });
|
return res.status(400).json({ error: 'first_name und last_name sind erforderlich' });
|
||||||
@@ -58,7 +96,7 @@ app.post('/api/employees', (req, res) => {
|
|||||||
res.status(201).json(db.prepare('SELECT * FROM employees WHERE id = ?').get(result.lastInsertRowid));
|
res.status(201).json(db.prepare('SELECT * FROM employees WHERE id = ?').get(result.lastInsertRowid));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/employees/:id', (req, res) => {
|
app.put('/api/employees/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { first_name, last_name, email, role, weekly_capacity_hours } = req.body;
|
const { first_name, last_name, email, role, weekly_capacity_hours } = req.body;
|
||||||
const existing = db.prepare('SELECT * FROM employees WHERE id = ?').get(req.params.id);
|
const existing = db.prepare('SELECT * FROM employees WHERE id = ?').get(req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||||
@@ -75,7 +113,7 @@ app.put('/api/employees/:id', (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM employees WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM employees WHERE id = ?').get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/employees/:id', (req, res) => {
|
app.delete('/api/employees/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
db.prepare('DELETE FROM employees WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM employees WHERE id = ?').run(req.params.id);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
@@ -98,7 +136,7 @@ app.get('/api/teams', (req, res) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/teams', (req, res) => {
|
app.post('/api/teams', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { name, description } = req.body;
|
const { name, description } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
|
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
|
||||||
const result = db
|
const result = db
|
||||||
@@ -107,7 +145,7 @@ app.post('/api/teams', (req, res) => {
|
|||||||
res.status(201).json({ ...db.prepare('SELECT * FROM teams WHERE id = ?').get(result.lastInsertRowid), members: [] });
|
res.status(201).json({ ...db.prepare('SELECT * FROM teams WHERE id = ?').get(result.lastInsertRowid), members: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/teams/:id', (req, res) => {
|
app.put('/api/teams/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { name, description } = req.body;
|
const { name, description } = req.body;
|
||||||
const existing = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
|
const existing = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||||
@@ -119,12 +157,12 @@ app.put('/api/teams/:id', (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/teams/:id', (req, res) => {
|
app.delete('/api/teams/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/teams/:id/members', (req, res) => {
|
app.post('/api/teams/:id/members', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { employee_id } = req.body;
|
const { employee_id } = req.body;
|
||||||
db.prepare('INSERT OR IGNORE INTO team_members (team_id, employee_id) VALUES (?, ?)').run(
|
db.prepare('INSERT OR IGNORE INTO team_members (team_id, employee_id) VALUES (?, ?)').run(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
@@ -133,7 +171,7 @@ app.post('/api/teams/:id/members', (req, res) => {
|
|||||||
res.status(201).end();
|
res.status(201).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/teams/:id/members/:employeeId', (req, res) => {
|
app.delete('/api/teams/:id/members/:employeeId', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
db.prepare('DELETE FROM team_members WHERE team_id = ? AND employee_id = ?').run(
|
db.prepare('DELETE FROM team_members WHERE team_id = ? AND employee_id = ?').run(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.params.employeeId
|
req.params.employeeId
|
||||||
@@ -167,7 +205,7 @@ app.get('/api/projects', (req, res) => {
|
|||||||
res.json(tasksByProject);
|
res.json(tasksByProject);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/projects', (req, res) => {
|
app.post('/api/projects', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { name, description, start_date, end_date, color } = req.body;
|
const { name, description, start_date, end_date, color } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
|
if (!name) return res.status(400).json({ error: 'name ist erforderlich' });
|
||||||
const result = db
|
const result = db
|
||||||
@@ -178,7 +216,7 @@ app.post('/api/projects', (req, res) => {
|
|||||||
res.status(201).json(db.prepare('SELECT * FROM projects WHERE id = ?').get(result.lastInsertRowid));
|
res.status(201).json(db.prepare('SELECT * FROM projects WHERE id = ?').get(result.lastInsertRowid));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/projects/:id', (req, res) => {
|
app.put('/api/projects/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id);
|
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||||
const { name, description, start_date, end_date, color } = req.body;
|
const { name, description, start_date, end_date, color } = req.body;
|
||||||
@@ -195,12 +233,12 @@ app.put('/api/projects/:id', (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/projects/:id', (req, res) => {
|
app.delete('/api/projects/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/tasks', (req, res) => {
|
app.post('/api/tasks', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { project_id, name, start_date, end_date, estimated_hours, status } = req.body;
|
const { project_id, name, start_date, end_date, estimated_hours, status } = req.body;
|
||||||
if (!project_id || !name || !start_date || !end_date) {
|
if (!project_id || !name || !start_date || !end_date) {
|
||||||
return res.status(400).json({ error: 'project_id, name, start_date, end_date sind erforderlich' });
|
return res.status(400).json({ error: 'project_id, name, start_date, end_date sind erforderlich' });
|
||||||
@@ -213,9 +251,23 @@ app.post('/api/tasks', (req, res) => {
|
|||||||
res.status(201).json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid));
|
res.status(201).json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/tasks/:id', (req, res) => {
|
app.put('/api/tasks/:id', requireRole('admin', 'teamleiter', 'mitarbeiter'), (req, res) => {
|
||||||
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
|
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||||
|
|
||||||
|
if (req.user.role === 'mitarbeiter') {
|
||||||
|
const assigned = db
|
||||||
|
.prepare('SELECT 1 FROM assignments WHERE task_id = ? AND employee_id = ?')
|
||||||
|
.get(req.params.id, req.user.employee_id);
|
||||||
|
if (!assigned) return res.status(403).json({ error: 'nicht zugewiesen' });
|
||||||
|
const keys = Object.keys(req.body);
|
||||||
|
if (keys.length === 0 || keys.some((k) => k !== 'status')) {
|
||||||
|
return res.status(400).json({ error: 'Mitarbeiter dürfen nur den Status ändern' });
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(req.body.status, req.params.id);
|
||||||
|
return res.json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id));
|
||||||
|
}
|
||||||
|
|
||||||
const { name, start_date, end_date, estimated_hours, status } = req.body;
|
const { name, start_date, end_date, estimated_hours, status } = req.body;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'UPDATE tasks SET name = ?, start_date = ?, end_date = ?, estimated_hours = ?, status = ? WHERE id = ?'
|
'UPDATE tasks SET name = ?, start_date = ?, end_date = ?, estimated_hours = ?, status = ? WHERE id = ?'
|
||||||
@@ -230,12 +282,12 @@ app.put('/api/tasks/:id', (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/tasks/:id', (req, res) => {
|
app.delete('/api/tasks/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/tasks/:id/duplicate', (req, res) => {
|
app.post('/api/tasks/:id/duplicate', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
|
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||||
const { start_date, end_date } = req.body;
|
const { start_date, end_date } = req.body;
|
||||||
@@ -267,7 +319,7 @@ app.post('/api/tasks/:id/duplicate', (req, res) => {
|
|||||||
res.status(201).json({ ...newTask, assignments: newAssignments, depends_on: [] });
|
res.status(201).json({ ...newTask, assignments: newAssignments, depends_on: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/tasks/:id/dependencies', (req, res) => {
|
app.post('/api/tasks/:id/dependencies', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { depends_on_task_id } = req.body;
|
const { depends_on_task_id } = req.body;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)'
|
'INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)'
|
||||||
@@ -275,7 +327,7 @@ app.post('/api/tasks/:id/dependencies', (req, res) => {
|
|||||||
res.status(201).end();
|
res.status(201).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/tasks/:id/dependencies/:dependsOnId', (req, res) => {
|
app.delete('/api/tasks/:id/dependencies/:dependsOnId', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?'
|
'DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?'
|
||||||
).run(req.params.id, req.params.dependsOnId);
|
).run(req.params.id, req.params.dependsOnId);
|
||||||
@@ -284,7 +336,7 @@ app.delete('/api/tasks/:id/dependencies/:dependsOnId', (req, res) => {
|
|||||||
|
|
||||||
// ---------- assignments ----------
|
// ---------- assignments ----------
|
||||||
|
|
||||||
app.post('/api/assignments', (req, res) => {
|
app.post('/api/assignments', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { task_id, employee_id, allocated_hours_per_week } = req.body;
|
const { task_id, employee_id, allocated_hours_per_week } = req.body;
|
||||||
if (!task_id || !employee_id) {
|
if (!task_id || !employee_id) {
|
||||||
return res.status(400).json({ error: 'task_id und employee_id sind erforderlich' });
|
return res.status(400).json({ error: 'task_id und employee_id sind erforderlich' });
|
||||||
@@ -301,7 +353,7 @@ app.post('/api/assignments', (req, res) => {
|
|||||||
res.status(201).json(db.prepare('SELECT * FROM assignments WHERE id = ?').get(id));
|
res.status(201).json(db.prepare('SELECT * FROM assignments WHERE id = ?').get(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/assignments/:id', (req, res) => {
|
app.put('/api/assignments/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
const { allocated_hours_per_week } = req.body;
|
const { allocated_hours_per_week } = req.body;
|
||||||
db.prepare('UPDATE assignments SET allocated_hours_per_week = ? WHERE id = ?').run(
|
db.prepare('UPDATE assignments SET allocated_hours_per_week = ? WHERE id = ?').run(
|
||||||
allocated_hours_per_week,
|
allocated_hours_per_week,
|
||||||
@@ -310,11 +362,146 @@ app.put('/api/assignments/:id', (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/assignments/:id', (req, res) => {
|
app.delete('/api/assignments/:id', requireRole('admin', 'teamleiter'), (req, res) => {
|
||||||
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- vacations ----------
|
||||||
|
|
||||||
|
function findVacationConflict({ role, substituteEmployeeId, startDate, endDate, excludeId }) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT v.id FROM vacations v
|
||||||
|
JOIN employees e ON e.id = v.employee_id
|
||||||
|
JOIN users u ON u.employee_id = e.id
|
||||||
|
WHERE u.role = ? AND v.substitute_employee_id = ? AND v.id != ?
|
||||||
|
AND v.start_date <= ? AND v.end_date >= ?`
|
||||||
|
)
|
||||||
|
.get(role, substituteEmployeeId, excludeId, endDate, startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vacationRow(id) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT v.*,
|
||||||
|
(emp.first_name || ' ' || emp.last_name) AS employee_name,
|
||||||
|
(sub.first_name || ' ' || sub.last_name) AS substitute_name
|
||||||
|
FROM vacations v
|
||||||
|
JOIN employees emp ON emp.id = v.employee_id
|
||||||
|
JOIN employees sub ON sub.id = v.substitute_employee_id
|
||||||
|
WHERE v.id = ?`
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/vacations', (req, res) => {
|
||||||
|
res.json(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT v.*,
|
||||||
|
(emp.first_name || ' ' || emp.last_name) AS employee_name,
|
||||||
|
(sub.first_name || ' ' || sub.last_name) AS substitute_name
|
||||||
|
FROM vacations v
|
||||||
|
JOIN employees emp ON emp.id = v.employee_id
|
||||||
|
JOIN employees sub ON sub.id = v.substitute_employee_id
|
||||||
|
ORDER BY v.start_date DESC`
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/vacations', requireRole('admin', 'teamleiter', 'mitarbeiter'), (req, res) => {
|
||||||
|
let { employee_id, start_date, end_date, substitute_employee_id, note } = req.body;
|
||||||
|
if (req.user.role === 'mitarbeiter') {
|
||||||
|
employee_id = req.user.employee_id;
|
||||||
|
}
|
||||||
|
if (!employee_id || !start_date || !end_date || !substitute_employee_id) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'employee_id, start_date, end_date und substitute_employee_id sind erforderlich' });
|
||||||
|
}
|
||||||
|
if (start_date > end_date) {
|
||||||
|
return res.status(400).json({ error: 'start_date darf nicht nach end_date liegen' });
|
||||||
|
}
|
||||||
|
if (Number(substitute_employee_id) === Number(employee_id)) {
|
||||||
|
return res.status(400).json({ error: 'Vertreter darf nicht die urlaubende Person selbst sein' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflict = findVacationConflict({
|
||||||
|
role: req.user.role,
|
||||||
|
substituteEmployeeId: substitute_employee_id,
|
||||||
|
startDate: start_date,
|
||||||
|
endDate: end_date,
|
||||||
|
excludeId: -1,
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
return res
|
||||||
|
.status(409)
|
||||||
|
.json({ error: 'Vertreter ist bereits für einen überlappenden Zeitraum in dieser Rolle eingetragen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO vacations (employee_id, start_date, end_date, substitute_employee_id, note) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.run(employee_id, start_date, end_date, substitute_employee_id, note || null);
|
||||||
|
res.status(201).json(vacationRow(result.lastInsertRowid));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/vacations/:id', requireRole('admin', 'teamleiter', 'mitarbeiter'), (req, res) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM vacations WHERE id = ?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||||
|
if (req.user.role === 'mitarbeiter' && existing.employee_id !== req.user.employee_id) {
|
||||||
|
return res.status(403).json({ error: 'keine Berechtigung' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const start_date = req.body.start_date ?? existing.start_date;
|
||||||
|
const end_date = req.body.end_date ?? existing.end_date;
|
||||||
|
const substitute_employee_id = req.body.substitute_employee_id ?? existing.substitute_employee_id;
|
||||||
|
const note = req.body.note ?? existing.note;
|
||||||
|
|
||||||
|
if (start_date > end_date) {
|
||||||
|
return res.status(400).json({ error: 'start_date darf nicht nach end_date liegen' });
|
||||||
|
}
|
||||||
|
if (Number(substitute_employee_id) === Number(existing.employee_id)) {
|
||||||
|
return res.status(400).json({ error: 'Vertreter darf nicht die urlaubende Person selbst sein' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterRole = db
|
||||||
|
.prepare('SELECT role FROM users WHERE employee_id = ?')
|
||||||
|
.get(existing.employee_id)?.role;
|
||||||
|
if (requesterRole) {
|
||||||
|
const conflict = findVacationConflict({
|
||||||
|
role: requesterRole,
|
||||||
|
substituteEmployeeId: substitute_employee_id,
|
||||||
|
startDate: start_date,
|
||||||
|
endDate: end_date,
|
||||||
|
excludeId: existing.id,
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
return res
|
||||||
|
.status(409)
|
||||||
|
.json({ error: 'Vertreter ist bereits für einen überlappenden Zeitraum in dieser Rolle eingetragen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE vacations SET start_date = ?, end_date = ?, substitute_employee_id = ?, note = ? WHERE id = ?'
|
||||||
|
).run(start_date, end_date, substitute_employee_id, note, req.params.id);
|
||||||
|
res.json(vacationRow(req.params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/vacations/:id', requireRole('admin', 'teamleiter', 'mitarbeiter'), (req, res) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM vacations WHERE id = ?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||||
|
if (req.user.role === 'mitarbeiter' && existing.employee_id !== req.user.employee_id) {
|
||||||
|
return res.status(403).json({ error: 'keine Berechtigung' });
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM vacations WHERE id = ?').run(req.params.id);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- utilization heatmap ----------
|
// ---------- utilization heatmap ----------
|
||||||
|
|
||||||
app.get('/api/utilization', (req, res) => {
|
app.get('/api/utilization', (req, res) => {
|
||||||
@@ -334,24 +521,42 @@ app.get('/api/utilization', (req, res) => {
|
|||||||
FROM assignments a JOIN tasks t ON t.id = a.task_id`
|
FROM assignments a JOIN tasks t ON t.id = a.task_id`
|
||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
|
const vacationRows = db.prepare('SELECT employee_id, start_date, end_date FROM vacations').all();
|
||||||
|
|
||||||
|
function overlapDaysInWeek(startStr, endStr, week) {
|
||||||
|
const rangeStart = parseDate(startStr);
|
||||||
|
const rangeEnd = parseDate(endStr);
|
||||||
|
const weekEnd = addDays(week.start, 6);
|
||||||
|
const overlapStart = rangeStart > week.start ? rangeStart : week.start;
|
||||||
|
const overlapEnd = rangeEnd < weekEnd ? rangeEnd : weekEnd;
|
||||||
|
return Math.max(0, Math.round((overlapEnd - overlapStart) / 86400000) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
const data = {};
|
const data = {};
|
||||||
for (const e of employees) data[e.id] = {};
|
const vacationDays = {};
|
||||||
|
for (const e of employees) {
|
||||||
|
data[e.id] = {};
|
||||||
|
vacationDays[e.id] = {};
|
||||||
|
}
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const taskStart = parseDate(row.start_date);
|
|
||||||
const taskEnd = parseDate(row.end_date);
|
|
||||||
for (const week of weeks) {
|
for (const week of weeks) {
|
||||||
const weekEnd = addDays(week.start, 6);
|
const overlapDays = overlapDaysInWeek(row.start_date, row.end_date, week);
|
||||||
const overlapStart = taskStart > week.start ? taskStart : week.start;
|
|
||||||
const overlapEnd = taskEnd < weekEnd ? taskEnd : weekEnd;
|
|
||||||
const overlapDays = Math.max(0, Math.round((overlapEnd - overlapStart) / 86400000) + 1);
|
|
||||||
if (overlapDays <= 0) continue;
|
if (overlapDays <= 0) continue;
|
||||||
const hours = row.allocated_hours_per_week * (overlapDays / 7);
|
const hours = row.allocated_hours_per_week * (overlapDays / 7);
|
||||||
data[row.employee_id][week.key] = (data[row.employee_id][week.key] || 0) + hours;
|
data[row.employee_id][week.key] = (data[row.employee_id][week.key] || 0) + hours;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const row of vacationRows) {
|
||||||
|
if (!vacationDays[row.employee_id]) continue;
|
||||||
|
for (const week of weeks) {
|
||||||
|
const overlapDays = overlapDaysInWeek(row.start_date, row.end_date, week);
|
||||||
|
if (overlapDays <= 0) continue;
|
||||||
|
vacationDays[row.employee_id][week.key] = (vacationDays[row.employee_id][week.key] || 0) + overlapDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
weeks: weeks.map((w) => w.key),
|
weeks: weeks.map((w) => w.key),
|
||||||
employees: employees.map((e) => ({
|
employees: employees.map((e) => ({
|
||||||
@@ -359,6 +564,10 @@ app.get('/api/utilization', (req, res) => {
|
|||||||
name: `${e.first_name} ${e.last_name}`,
|
name: `${e.first_name} ${e.last_name}`,
|
||||||
weekly_capacity_hours: e.weekly_capacity_hours,
|
weekly_capacity_hours: e.weekly_capacity_hours,
|
||||||
hours: weeks.map((w) => Math.round((data[e.id][w.key] || 0) * 10) / 10),
|
hours: weeks.map((w) => Math.round((data[e.id][w.key] || 0) * 10) / 10),
|
||||||
|
capacity: weeks.map((w) => {
|
||||||
|
const days = Math.min(7, vacationDays[e.id][w.key] || 0);
|
||||||
|
return Math.round(e.weekly_capacity_hours * (1 - days / 7) * 10) / 10;
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -367,7 +576,7 @@ app.get('/health', (req, res) => res.json({ ok: true }));
|
|||||||
|
|
||||||
// ---------- backup ----------
|
// ---------- backup ----------
|
||||||
|
|
||||||
app.post('/api/backup', async (req, res) => {
|
app.post('/api/backup', requireRole('admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const file = await runBackup();
|
const file = await runBackup();
|
||||||
res.status(201).json({ file });
|
res.status(201).json({ file });
|
||||||
@@ -376,6 +585,59 @@ app.post('/api/backup', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- users (Benutzerverwaltung) ----------
|
||||||
|
|
||||||
|
app.get('/api/users', requireRole('admin'), (req, res) => {
|
||||||
|
res.json(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.employee_id, u.email, u.role,
|
||||||
|
(e.first_name || ' ' || e.last_name) AS employee_name
|
||||||
|
FROM users u LEFT JOIN employees e ON e.id = u.employee_id
|
||||||
|
ORDER BY u.email`
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users', requireRole('admin'), (req, res) => {
|
||||||
|
const { employee_id, email, role } = req.body;
|
||||||
|
if (!email || !role) {
|
||||||
|
return res.status(400).json({ error: 'email und role sind erforderlich' });
|
||||||
|
}
|
||||||
|
// Passwort wird bewusst nicht hier gesetzt: der Mitarbeiter vergibt es selbst beim ersten Login.
|
||||||
|
const result = db
|
||||||
|
.prepare('INSERT INTO users (employee_id, email, password_hash, role) VALUES (?, ?, NULL, ?)')
|
||||||
|
.run(employee_id || null, email, role);
|
||||||
|
res.status(201).json(
|
||||||
|
db
|
||||||
|
.prepare('SELECT id, employee_id, email, role FROM users WHERE id = ?')
|
||||||
|
.get(result.lastInsertRowid)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/users/:id', requireRole('admin'), (req, res) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'nicht gefunden' });
|
||||||
|
const email = req.body.email ?? existing.email;
|
||||||
|
const role = req.body.role ?? existing.role;
|
||||||
|
// reset_password: true setzt den Hash zurück auf NULL, das Konto vergibt beim
|
||||||
|
// nächsten Login wieder ein neues Passwort (gleicher Mechanismus wie bei Neuanlage).
|
||||||
|
const passwordHash = req.body.reset_password ? null : existing.password_hash;
|
||||||
|
db.prepare('UPDATE users SET email = ?, role = ?, password_hash = ? WHERE id = ?').run(
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
passwordHash,
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
res.json(db.prepare('SELECT id, employee_id, email, role FROM users WHERE id = ?').get(req.params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:id', requireRole('admin'), (req, res) => {
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
// serve built frontend in production
|
// serve built frontend in production
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -387,6 +649,16 @@ if (fs.existsSync(clientDist)) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generischer Fehler-Handler: SQLite-Fremdschlüsselverletzungen (z. B. Mitarbeiter
|
||||||
|
// löschen, der noch als Urlaubsvertreter eingetragen ist) sauber als 409 statt 500 melden
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (err && err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') {
|
||||||
|
return res.status(409).json({ error: 'Datensatz wird an anderer Stelle noch referenziert' });
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'interner Fehler' });
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Ressourcenplanung-API läuft auf http://localhost:${PORT}`);
|
console.log(`Ressourcenplanung-API läuft auf http://localhost:${PORT}`);
|
||||||
startBackupScheduler();
|
startBackupScheduler();
|
||||||
|
|||||||
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "ressourcenplanung-server",
|
"name": "ressourcenplanung-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2"
|
||||||
@@ -52,6 +53,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "11.10.0",
|
"version": "11.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"start": "node index.js"
|
"start": "node index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2"
|
||||||
|
|||||||
@@ -53,3 +53,29 @@ CREATE TABLE IF NOT EXISTS assignments (
|
|||||||
allocated_hours_per_week REAL NOT NULL DEFAULT 0,
|
allocated_hours_per_week REAL NOT NULL DEFAULT 0,
|
||||||
UNIQUE (task_id, employee_id)
|
UNIQUE (task_id, employee_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
employee_id INTEGER UNIQUE REFERENCES employees(id) ON DELETE SET NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'mitarbeiter' CHECK (role IN ('admin','teamleiter','mitarbeiter','beobachter'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vacations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||||
|
start_date TEXT NOT NULL,
|
||||||
|
end_date TEXT NOT NULL,
|
||||||
|
substitute_employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||||
|
note TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vacations_employee ON vacations(employee_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vacations_substitute ON vacations(substitute_employee_id);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Einmalig beim Umstieg von der alten Version (ohne Login) auf die neue Version
|
||||||
|
// (mit Benutzerverwaltung) auszuführen: legt für jeden bestehenden Mitarbeiter
|
||||||
|
// mit hinterlegter E-Mail, der noch KEIN Benutzerkonto hat, automatisch eines an
|
||||||
|
// (Rolle 'mitarbeiter', ohne Passwort — wie bei der Benutzerverwaltung üblich wird
|
||||||
|
// das Passwort beim ersten Login vom jeweiligen Mitarbeiter selbst vergeben).
|
||||||
|
//
|
||||||
|
// Bestehende Projekte, Aufgaben, Zuweisungen und Teams werden dabei nicht
|
||||||
|
// verändert — die Tabellen werden vom Server ohnehin additiv angelegt
|
||||||
|
// (server/schema.sql, CREATE TABLE IF NOT EXISTS). Dieses Skript kümmert sich
|
||||||
|
// ausschließlich darum, dass alle bestehenden Teammitglieder auch einen Login
|
||||||
|
// bekommen, statt sie einzeln über die Benutzerverwaltung anlegen zu müssen.
|
||||||
|
//
|
||||||
|
// Aufruf:
|
||||||
|
// node scripts/migrate-team-accounts.js
|
||||||
|
// docker exec <container> node scripts/migrate-team-accounts.js (im laufenden Container)
|
||||||
|
//
|
||||||
|
// Rolle abweichend vom Default vergeben (z. B. alle als Teamleiter anlegen):
|
||||||
|
// node scripts/migrate-team-accounts.js --role teamleiter
|
||||||
|
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
function readArg(name) {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
return idx !== -1 ? process.argv[idx + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = readArg('role') || 'mitarbeiter';
|
||||||
|
const VALID_ROLES = ['admin', 'teamleiter', 'mitarbeiter', 'beobachter'];
|
||||||
|
if (!VALID_ROLES.includes(role)) {
|
||||||
|
console.error(`Ungültige Rolle "${role}". Erlaubt: ${VALID_ROLES.join(', ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employees = db.prepare('SELECT id, first_name, last_name, email FROM employees ORDER BY last_name, first_name').all();
|
||||||
|
const existingUsers = db.prepare('SELECT employee_id, email FROM users').all();
|
||||||
|
const employeeIdsWithAccount = new Set(existingUsers.map((u) => u.employee_id));
|
||||||
|
const emailsInUse = new Set(existingUsers.map((u) => u.email));
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT INTO users (employee_id, email, password_hash, role) VALUES (?, ?, NULL, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let skippedNoEmail = 0;
|
||||||
|
let skippedExisting = 0;
|
||||||
|
let skippedEmailConflict = 0;
|
||||||
|
|
||||||
|
for (const e of employees) {
|
||||||
|
const name = `${e.first_name} ${e.last_name}`;
|
||||||
|
|
||||||
|
if (employeeIdsWithAccount.has(e.id)) {
|
||||||
|
skippedExisting += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!e.email) {
|
||||||
|
console.log(`Übersprungen (keine E-Mail hinterlegt): ${name}`);
|
||||||
|
skippedNoEmail += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (emailsInUse.has(e.email)) {
|
||||||
|
console.log(`Übersprungen (E-Mail bereits von einem anderen Konto verwendet): ${name} <${e.email}>`);
|
||||||
|
skippedEmailConflict += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
insert.run(e.id, e.email, role);
|
||||||
|
emailsInUse.add(e.email);
|
||||||
|
created += 1;
|
||||||
|
console.log(`Konto angelegt: ${name} <${e.email}> (Rolle: ${role})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('----------------------------------------------------------------');
|
||||||
|
console.log(`Fertig. Neu angelegt: ${created}, bereits vorhanden: ${skippedExisting}, ` +
|
||||||
|
`ohne E-Mail übersprungen: ${skippedNoEmail}, E-Mail-Konflikt übersprungen: ${skippedEmailConflict}.`);
|
||||||
|
console.log('Alle neu angelegten Konten vergeben ihr Passwort selbst beim ersten Login.');
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Legt einen Admin-Zugang für einen bestehenden Mitarbeiter an oder setzt ihn zurück.
|
||||||
|
//
|
||||||
|
// Ohne --password: Konto wird (neu) angelegt, das Passwort wird beim nächsten
|
||||||
|
// Login vom Nutzer selbst vergeben (gleicher Mechanismus wie bei der Benutzerverwaltung).
|
||||||
|
// Mit --password: setzt sofort ein Passwort (Notfall-Wiederherstellung, falls der
|
||||||
|
// Admin sich ausgesperrt hat).
|
||||||
|
//
|
||||||
|
// Aufruf:
|
||||||
|
// node scripts/reset-admin.js --employee-id 3
|
||||||
|
// node scripts/reset-admin.js --email admin@example.com --password neuesPasswort
|
||||||
|
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
function readArg(name) {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
return idx !== -1 ? process.argv[idx + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeId = readArg('employee-id');
|
||||||
|
let email = readArg('email');
|
||||||
|
const password = readArg('password');
|
||||||
|
|
||||||
|
if (!employeeId && !email) {
|
||||||
|
console.error(
|
||||||
|
'Nutzung: node scripts/reset-admin.js --employee-id <id> [--password <passwort>]\n' +
|
||||||
|
' oder node scripts/reset-admin.js --email <email> [--password <passwort>]'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employeeId && !email) {
|
||||||
|
const employee = db.prepare('SELECT email FROM employees WHERE id = ?').get(employeeId);
|
||||||
|
if (!employee || !employee.email) {
|
||||||
|
console.error(`Mitarbeiter ${employeeId} wurde nicht gefunden oder hat keine E-Mail hinterlegt.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
email = employee.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO users (employee_id, email, password_hash, role) VALUES (?, ?, ?, 'admin')
|
||||||
|
ON CONFLICT (email) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash`
|
||||||
|
).run(employeeId || null, email, passwordHash);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
password
|
||||||
|
? `Admin-Zugang für ${email} wurde mit dem angegebenen Passwort gesetzt.`
|
||||||
|
: `Admin-Zugang für ${email} wurde angelegt/zurückgesetzt. Passwort wird beim nächsten Login vergeben.`
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user