Initial commit: Ressourcenplanung-App
Vue 3 + Vite Frontend und Node/Express + SQLite Backend für Projekt-, Aufgaben-, Mitarbeiter- und Teamverwaltung inkl. Gantt-Zeitplan und Auslastungs-Heatmap.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ressourcenplanung</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1207
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "ressourcenplanung-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { store } from './store.js';
|
||||
import SchedulePanel from './SchedulePanel.vue';
|
||||
import UtilizationPanel from './UtilizationPanel.vue';
|
||||
import ProjectsPanel from './ProjectsPanel.vue';
|
||||
import EmployeesPanel from './EmployeesPanel.vue';
|
||||
import TeamsPanel from './TeamsPanel.vue';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'schedule', label: 'Zeitplan' },
|
||||
{ id: 'utilization', label: 'Auslastung' },
|
||||
{ id: 'projects', label: 'Projekte' },
|
||||
{ id: 'employees', label: 'Mitarbeiter' },
|
||||
{ id: 'teams', label: 'Teams' },
|
||||
];
|
||||
const activeTab = ref('schedule');
|
||||
|
||||
onMounted(() => store.loadAll());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<h1>Ressourcenplanung</h1>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p v-if="store.loading" class="muted">Lade Daten…</p>
|
||||
<p v-if="store.error" class="muted" style="color: #ef4444">{{ store.error }}</p>
|
||||
<template v-else>
|
||||
<SchedulePanel v-if="activeTab === 'schedule'" />
|
||||
<UtilizationPanel v-if="activeTab === 'utilization'" />
|
||||
<ProjectsPanel v-if="activeTab === 'projects'" />
|
||||
<EmployeesPanel v-if="activeTab === 'employees'" />
|
||||
<TeamsPanel v-if="activeTab === 'teams'" />
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { store } from './store.js';
|
||||
|
||||
const newEmployee = reactive({ name: '', email: '', role: '', weekly_capacity_hours: 40 });
|
||||
|
||||
async function addEmployee() {
|
||||
if (!newEmployee.name) return;
|
||||
await store.createEmployee({ ...newEmployee });
|
||||
Object.assign(newEmployee, { name: '', email: '', role: '', weekly_capacity_hours: 40 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<h2>Neuer Mitarbeiter</h2>
|
||||
<div class="form-row">
|
||||
<input v-model="newEmployee.name" placeholder="Name" />
|
||||
<input v-model="newEmployee.email" placeholder="E-Mail" />
|
||||
<input v-model="newEmployee.role" placeholder="Rolle" />
|
||||
<input v-model.number="newEmployee.weekly_capacity_hours" type="number" style="width: 100px" placeholder="Std/Woche" />
|
||||
<button class="btn" @click="addEmployee">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2>Mitarbeiter</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Kapazität (Std/Woche)</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in store.employees" :key="e.id">
|
||||
<td>{{ e.name }}</td>
|
||||
<td>{{ e.email }}</td>
|
||||
<td>{{ e.role }}</td>
|
||||
<td>{{ e.weekly_capacity_hours }}</td>
|
||||
<td><button class="btn danger" @click="store.deleteEmployee(e.id)">Löschen</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="store.employees.length === 0" class="muted">Noch keine Mitarbeiter angelegt.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { store } from './store.js';
|
||||
import { formatDate } from './helpers.js';
|
||||
|
||||
const newProject = reactive({ name: '', description: '', start_date: '', end_date: '', color: '#4f46e5' });
|
||||
const newTask = reactive({});
|
||||
const newAssignment = reactive({});
|
||||
const expanded = reactive({});
|
||||
|
||||
async function addProject() {
|
||||
if (!newProject.name) return;
|
||||
await store.createProject({ ...newProject });
|
||||
Object.assign(newProject, { name: '', description: '', start_date: '', end_date: '', color: '#4f46e5' });
|
||||
}
|
||||
|
||||
function taskForm(projectId) {
|
||||
if (!newTask[projectId]) newTask[projectId] = { name: '', start_date: '', end_date: '', estimated_hours: 0, status: 'geplant' };
|
||||
return newTask[projectId];
|
||||
}
|
||||
|
||||
async function addTask(projectId) {
|
||||
const form = taskForm(projectId);
|
||||
if (!form.name || !form.start_date || !form.end_date) return;
|
||||
await store.createTask(projectId, { ...form });
|
||||
Object.assign(form, { name: '', start_date: '', end_date: '', estimated_hours: 0, status: 'geplant' });
|
||||
}
|
||||
|
||||
function assignmentForm(taskId) {
|
||||
if (!newAssignment[taskId]) newAssignment[taskId] = { employee_id: '', allocated_hours_per_week: 8 };
|
||||
return newAssignment[taskId];
|
||||
}
|
||||
|
||||
async function addAssignment(projectId, taskId) {
|
||||
const form = assignmentForm(taskId);
|
||||
if (!form.employee_id) return;
|
||||
await store.assignEmployee(projectId, taskId, Number(form.employee_id), Number(form.allocated_hours_per_week));
|
||||
Object.assign(form, { employee_id: '', allocated_hours_per_week: 8 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<h2>Neues Projekt</h2>
|
||||
<div class="form-row">
|
||||
<input v-model="newProject.name" placeholder="Projektname" />
|
||||
<input v-model="newProject.description" placeholder="Beschreibung" />
|
||||
<input v-model="newProject.start_date" type="date" />
|
||||
<input v-model="newProject.end_date" type="date" />
|
||||
<input v-model="newProject.color" type="color" />
|
||||
<button class="btn" @click="addProject">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section" v-for="project in store.projects" :key="project.id">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<h2 :style="{ color: project.color }">{{ project.name }}</h2>
|
||||
<button class="btn secondary" @click="expanded[project.id] = !expanded[project.id]">
|
||||
{{ expanded[project.id] ? 'Zuklappen' : 'Aufgaben verwalten' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted">{{ project.description }} · {{ formatDate(project.start_date) }} – {{ formatDate(project.end_date) }}</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Aufgabe</th>
|
||||
<th>Start</th>
|
||||
<th>Ende</th>
|
||||
<th>Stunden</th>
|
||||
<th>Status</th>
|
||||
<th>Zugewiesen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in project.tasks" :key="task.id">
|
||||
<td>{{ task.name }}</td>
|
||||
<td>{{ formatDate(task.start_date) }}</td>
|
||||
<td>{{ formatDate(task.end_date) }}</td>
|
||||
<td>{{ task.estimated_hours }}</td>
|
||||
<td>{{ task.status }}</td>
|
||||
<td>
|
||||
<span v-for="a in task.assignments" :key="a.id" class="tag">
|
||||
{{ a.employee_name }} ({{ a.allocated_hours_per_week }}h)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<template v-if="expanded[project.id]">
|
||||
<h3>Neue Aufgabe</h3>
|
||||
<div class="form-row">
|
||||
<input v-model="taskForm(project.id).name" placeholder="Aufgabenname" />
|
||||
<input v-model="taskForm(project.id).start_date" type="date" />
|
||||
<input v-model="taskForm(project.id).end_date" type="date" />
|
||||
<input v-model.number="taskForm(project.id).estimated_hours" type="number" placeholder="Stunden" style="width: 90px" />
|
||||
<select v-model="taskForm(project.id).status">
|
||||
<option value="geplant">geplant</option>
|
||||
<option value="in Arbeit">in Arbeit</option>
|
||||
<option value="erledigt">erledigt</option>
|
||||
</select>
|
||||
<button class="btn" @click="addTask(project.id)">Hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<div v-for="task in project.tasks" :key="'assign-' + task.id" class="form-row">
|
||||
<span style="width: 180px">{{ task.name }}:</span>
|
||||
<select v-model="assignmentForm(task.id).employee_id">
|
||||
<option value="" disabled>Mitarbeiter wählen</option>
|
||||
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ e.name }}</option>
|
||||
</select>
|
||||
<input v-model.number="assignmentForm(task.id).allocated_hours_per_week" type="number" style="width: 90px" placeholder="Std/Woche" />
|
||||
<button class="btn secondary" @click="addAssignment(project.id, task.id)">Zuweisen</button>
|
||||
<button class="btn danger" @click="store.deleteTask(project.id, task.id)">Aufgabe löschen</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div style="margin-top: 12px">
|
||||
<button class="btn danger" @click="store.deleteProject(project.id)">Projekt löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="store.projects.length === 0" class="muted">Noch keine Projekte angelegt.</p>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { store } from './store.js';
|
||||
import { ganttRange, dayOffset, daysBetween, formatDate, statusColor } from './helpers.js';
|
||||
|
||||
const range = computed(() => ganttRange(store.projects));
|
||||
const totalDays = computed(() => Math.max(1, dayOffset(range.value.min, range.value.max.toISOString()) + 1));
|
||||
|
||||
function barStyle(task) {
|
||||
const left = (dayOffset(range.value.min, task.start_date) / totalDays.value) * 100;
|
||||
const width = (daysBetween(task.start_date, task.end_date) / totalDays.value) * 100;
|
||||
return {
|
||||
left: `${left}%`,
|
||||
width: `${Math.max(width, 1.5)}%`,
|
||||
background: statusColor(task.status),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<h2>Zeitplan</h2>
|
||||
<p class="muted">
|
||||
{{ formatDate(range.min.toISOString()) }} – {{ formatDate(range.max.toISOString()) }}
|
||||
</p>
|
||||
|
||||
<div v-for="project in store.projects" :key="project.id" style="margin-bottom: 20px">
|
||||
<strong :style="{ color: project.color }">{{ project.name }}</strong>
|
||||
<div v-for="task in project.tasks" :key="task.id" class="gantt-row">
|
||||
<div class="gantt-label">{{ task.name }}</div>
|
||||
<div class="gantt-track">
|
||||
<div class="gantt-bar" :style="barStyle(task)" :title="`${formatDate(task.start_date)} – ${formatDate(task.end_date)}`">
|
||||
{{ task.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="project.tasks.length === 0" class="muted">Keine Aufgaben.</p>
|
||||
</div>
|
||||
<p v-if="store.projects.length === 0" class="muted">Keine Projekte vorhanden.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { store } from './store.js';
|
||||
|
||||
const newTeam = reactive({ name: '', description: '' });
|
||||
const newMember = reactive({});
|
||||
|
||||
async function addTeam() {
|
||||
if (!newTeam.name) return;
|
||||
await store.createTeam({ ...newTeam });
|
||||
Object.assign(newTeam, { name: '', description: '' });
|
||||
}
|
||||
|
||||
function memberForm(teamId) {
|
||||
if (!newMember[teamId]) newMember[teamId] = '';
|
||||
return newMember;
|
||||
}
|
||||
|
||||
async function addMember(teamId) {
|
||||
const employeeId = newMember[teamId];
|
||||
if (!employeeId) return;
|
||||
await store.addTeamMember(teamId, Number(employeeId));
|
||||
newMember[teamId] = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<h2>Neues Team</h2>
|
||||
<div class="form-row">
|
||||
<input v-model="newTeam.name" placeholder="Teamname" />
|
||||
<input v-model="newTeam.description" placeholder="Beschreibung" />
|
||||
<button class="btn" @click="addTeam">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section" v-for="team in store.teams" :key="team.id">
|
||||
<h2>{{ team.name }}</h2>
|
||||
<p class="muted">{{ team.description }}</p>
|
||||
|
||||
<span v-for="m in team.members" :key="m.id" class="tag">
|
||||
{{ m.name }}
|
||||
<a href="#" @click.prevent="store.removeTeamMember(team.id, m.id)" style="margin-left: 4px">×</a>
|
||||
</span>
|
||||
<p v-if="team.members.length === 0" class="muted">Keine Mitglieder.</p>
|
||||
|
||||
<div class="form-row">
|
||||
<select v-model="memberForm(team.id)[team.id]">
|
||||
<option value="" disabled>Mitarbeiter hinzufügen</option>
|
||||
<option v-for="e in store.employees" :key="e.id" :value="e.id">{{ e.name }}</option>
|
||||
</select>
|
||||
<button class="btn secondary" @click="addMember(team.id)">Hinzufügen</button>
|
||||
<button class="btn danger" @click="store.deleteTeam(team.id)">Team löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="store.teams.length === 0" class="muted">Noch keine Teams angelegt.</p>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { store } from './store.js';
|
||||
import { utilizationColor } from './helpers.js';
|
||||
|
||||
const utilization = computed(() => store.utilization);
|
||||
|
||||
function percent(hours, capacity) {
|
||||
if (!capacity) return 0;
|
||||
return Math.round((hours / capacity) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-section">
|
||||
<h2>Auslastung</h2>
|
||||
<p class="muted">Geplante Stunden pro Woche im Verhältnis zur Kapazität je Mitarbeiter.</p>
|
||||
|
||||
<div v-if="utilization" style="overflow-x: auto">
|
||||
<table class="heatmap-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left">Mitarbeiter</th>
|
||||
<th v-for="week in utilization.weeks" :key="week">{{ week }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="employee in utilization.employees" :key="employee.id">
|
||||
<td style="text-align: left">{{ employee.name }}</td>
|
||||
<td v-for="(hours, idx) in employee.hours" :key="idx">
|
||||
<div
|
||||
class="heatmap-cell"
|
||||
:style="{ background: utilizationColor(percent(hours, employee.weekly_capacity_hours)) }"
|
||||
:title="`${hours}h von ${employee.weekly_capacity_hours}h`"
|
||||
>
|
||||
{{ percent(hours, employee.weekly_capacity_hours) }}%
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="muted">Lade Auslastung…</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
async function request(method, url, body) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${url} fehlgeschlagen: ${res.status} ${text}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// employees
|
||||
getEmployees: () => request('GET', '/api/employees'),
|
||||
createEmployee: (data) => request('POST', '/api/employees', data),
|
||||
updateEmployee: (id, data) => request('PUT', `/api/employees/${id}`, data),
|
||||
deleteEmployee: (id) => request('DELETE', `/api/employees/${id}`),
|
||||
|
||||
// teams
|
||||
getTeams: () => request('GET', '/api/teams'),
|
||||
createTeam: (data) => request('POST', '/api/teams', data),
|
||||
updateTeam: (id, data) => request('PUT', `/api/teams/${id}`, data),
|
||||
deleteTeam: (id) => request('DELETE', `/api/teams/${id}`),
|
||||
addTeamMember: (teamId, employeeId) =>
|
||||
request('POST', `/api/teams/${teamId}/members`, { employee_id: employeeId }),
|
||||
removeTeamMember: (teamId, employeeId) =>
|
||||
request('DELETE', `/api/teams/${teamId}/members/${employeeId}`),
|
||||
|
||||
// projects & tasks
|
||||
getProjects: () => request('GET', '/api/projects'),
|
||||
createProject: (data) => request('POST', '/api/projects', data),
|
||||
updateProject: (id, data) => request('PUT', `/api/projects/${id}`, data),
|
||||
deleteProject: (id) => request('DELETE', `/api/projects/${id}`),
|
||||
|
||||
createTask: (data) => request('POST', '/api/tasks', data),
|
||||
updateTask: (id, data) => request('PUT', `/api/tasks/${id}`, data),
|
||||
deleteTask: (id) => request('DELETE', `/api/tasks/${id}`),
|
||||
addDependency: (taskId, dependsOnId) =>
|
||||
request('POST', `/api/tasks/${taskId}/dependencies`, { depends_on_task_id: dependsOnId }),
|
||||
removeDependency: (taskId, dependsOnId) =>
|
||||
request('DELETE', `/api/tasks/${taskId}/dependencies/${dependsOnId}`),
|
||||
|
||||
// assignments
|
||||
createAssignment: (data) => request('POST', '/api/assignments', data),
|
||||
updateAssignment: (id, data) => request('PUT', `/api/assignments/${id}`, data),
|
||||
deleteAssignment: (id) => request('DELETE', `/api/assignments/${id}`),
|
||||
|
||||
// utilization
|
||||
getUtilization: (params = {}) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return request('GET', `/api/utilization${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
export function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function daysBetween(startStr, endStr) {
|
||||
const start = new Date(startStr);
|
||||
const end = new Date(endStr);
|
||||
return Math.round((end - start) / 86400000) + 1;
|
||||
}
|
||||
|
||||
export function ganttRange(projects) {
|
||||
const allDates = projects
|
||||
.flatMap((p) => p.tasks.flatMap((t) => [t.start_date, t.end_date]))
|
||||
.filter(Boolean)
|
||||
.map((d) => new Date(d));
|
||||
if (allDates.length === 0) {
|
||||
const today = new Date();
|
||||
return { min: today, max: new Date(today.getTime() + 30 * 86400000) };
|
||||
}
|
||||
return {
|
||||
min: new Date(Math.min(...allDates)),
|
||||
max: new Date(Math.max(...allDates)),
|
||||
};
|
||||
}
|
||||
|
||||
export function dayOffset(min, dateStr) {
|
||||
return Math.round((new Date(dateStr) - min) / 86400000);
|
||||
}
|
||||
|
||||
export function utilizationColor(percent) {
|
||||
if (percent === 0) return '#e5e7eb';
|
||||
if (percent < 50) return '#bbf7d0';
|
||||
if (percent <= 100) return '#4ade80';
|
||||
if (percent <= 120) return '#fbbf24';
|
||||
return '#f87171';
|
||||
}
|
||||
|
||||
export function statusColor(status) {
|
||||
switch (status) {
|
||||
case 'erledigt':
|
||||
return '#22c55e';
|
||||
case 'in Arbeit':
|
||||
return '#3b82f6';
|
||||
default:
|
||||
return '#94a3b8';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './panel.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1,190 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f8fafc;
|
||||
--panel: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--accent: #4f46e5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 24px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
header.topbar h1 {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
nav.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
nav.tabs button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
nav.tabs button.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-section h2 {
|
||||
margin-top: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
input, select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button.btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
button.btn.danger {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gantt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.gantt-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gantt-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gantt-label {
|
||||
width: 220px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.heatmap-table td, .heatmap-table th {
|
||||
text-align: center;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: #eef2ff;
|
||||
color: var(--accent);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { reactive } from 'vue';
|
||||
import { api } from './api.js';
|
||||
|
||||
export const store = reactive({
|
||||
employees: [],
|
||||
teams: [],
|
||||
projects: [],
|
||||
utilization: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
async loadAll() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const [employees, teams, projects] = await Promise.all([
|
||||
api.getEmployees(),
|
||||
api.getTeams(),
|
||||
api.getProjects(),
|
||||
]);
|
||||
this.employees = employees;
|
||||
this.teams = teams;
|
||||
this.projects = projects;
|
||||
await this.loadUtilization();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadUtilization(params) {
|
||||
this.utilization = await api.getUtilization(params);
|
||||
},
|
||||
|
||||
// employees
|
||||
async createEmployee(data) {
|
||||
this.employees.push(await api.createEmployee(data));
|
||||
await this.loadUtilization();
|
||||
},
|
||||
async updateEmployee(id, data) {
|
||||
const updated = await api.updateEmployee(id, data);
|
||||
const idx = this.employees.findIndex((e) => e.id === id);
|
||||
if (idx !== -1) this.employees[idx] = updated;
|
||||
},
|
||||
async deleteEmployee(id) {
|
||||
await api.deleteEmployee(id);
|
||||
this.employees = this.employees.filter((e) => e.id !== id);
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
// teams
|
||||
async createTeam(data) {
|
||||
this.teams.push(await api.createTeam(data));
|
||||
},
|
||||
async updateTeam(id, data) {
|
||||
const updated = await api.updateTeam(id, data);
|
||||
const idx = this.teams.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) this.teams[idx] = { ...updated, members: this.teams[idx].members };
|
||||
},
|
||||
async deleteTeam(id) {
|
||||
await api.deleteTeam(id);
|
||||
this.teams = this.teams.filter((t) => t.id !== id);
|
||||
},
|
||||
async addTeamMember(teamId, employeeId) {
|
||||
await api.addTeamMember(teamId, employeeId);
|
||||
const team = this.teams.find((t) => t.id === teamId);
|
||||
const employee = this.employees.find((e) => e.id === employeeId);
|
||||
if (team && employee && !team.members.some((m) => m.id === employeeId)) {
|
||||
team.members.push(employee);
|
||||
}
|
||||
},
|
||||
async removeTeamMember(teamId, employeeId) {
|
||||
await api.removeTeamMember(teamId, employeeId);
|
||||
const team = this.teams.find((t) => t.id === teamId);
|
||||
if (team) team.members = team.members.filter((m) => m.id !== employeeId);
|
||||
},
|
||||
|
||||
// projects
|
||||
async createProject(data) {
|
||||
this.projects.push({ ...(await api.createProject(data)), tasks: [] });
|
||||
},
|
||||
async updateProject(id, data) {
|
||||
const updated = await api.updateProject(id, data);
|
||||
const project = this.projects.find((p) => p.id === id);
|
||||
if (project) Object.assign(project, updated);
|
||||
},
|
||||
async deleteProject(id) {
|
||||
await api.deleteProject(id);
|
||||
this.projects = this.projects.filter((p) => p.id !== id);
|
||||
},
|
||||
|
||||
// tasks
|
||||
async createTask(projectId, data) {
|
||||
const task = await api.createTask({ ...data, project_id: projectId });
|
||||
const project = this.projects.find((p) => p.id === projectId);
|
||||
if (project) project.tasks.push({ ...task, assignments: [], depends_on: [] });
|
||||
await this.loadUtilization();
|
||||
},
|
||||
async updateTask(projectId, taskId, data) {
|
||||
const updated = await api.updateTask(taskId, data);
|
||||
const project = this.projects.find((p) => p.id === projectId);
|
||||
const task = project?.tasks.find((t) => t.id === taskId);
|
||||
if (task) Object.assign(task, updated);
|
||||
await this.loadUtilization();
|
||||
},
|
||||
async deleteTask(projectId, taskId) {
|
||||
await api.deleteTask(taskId);
|
||||
const project = this.projects.find((p) => p.id === projectId);
|
||||
if (project) project.tasks = project.tasks.filter((t) => t.id !== taskId);
|
||||
await this.loadUtilization();
|
||||
},
|
||||
|
||||
// assignments
|
||||
async assignEmployee(projectId, taskId, employeeId, hoursPerWeek) {
|
||||
const assignment = await api.createAssignment({
|
||||
task_id: taskId,
|
||||
employee_id: employeeId,
|
||||
allocated_hours_per_week: hoursPerWeek,
|
||||
});
|
||||
const project = this.projects.find((p) => p.id === projectId);
|
||||
const task = project?.tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
const employee = this.employees.find((e) => e.id === employeeId);
|
||||
const existing = task.assignments.find((a) => a.employee_id === employeeId);
|
||||
if (existing) Object.assign(existing, assignment, { employee_name: employee?.name });
|
||||
else task.assignments.push({ ...assignment, employee_name: employee?.name });
|
||||
}
|
||||
await this.loadUtilization();
|
||||
},
|
||||
async unassignEmployee(projectId, taskId, assignmentId) {
|
||||
await api.deleteAssignment(assignmentId);
|
||||
const project = this.projects.find((p) => p.id === projectId);
|
||||
const task = project?.tasks.find((t) => t.id === taskId);
|
||||
if (task) task.assignments = task.assignments.filter((a) => a.id !== assignmentId);
|
||||
await this.loadUtilization();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/health': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user