ready to deployment

This commit is contained in:
2025-08-10 21:50:14 +02:00
commit 1efb04b057
37 changed files with 7379 additions and 0 deletions

8
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
frontend/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

31
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
src/assets/*.json

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

8
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

35
frontend/README.md Normal file
View File

@@ -0,0 +1,35 @@
# .
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

26
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
skipFormatting,
])

23
frontend/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

4763
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "odoo_hours",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@jamescoyle/vue-icon": "^0.1.2",
"@mdi/js": "^7.4.47",
"axios": "^1.11.0",
"chart.js": "^4.5.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.31.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"eslint": "^9.31.0",
"eslint-plugin-vue": "~10.3.0",
"globals": "^16.3.0",
"prettier": "3.6.2",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

38
frontend/src/api/api.js Normal file
View File

@@ -0,0 +1,38 @@
// src/api/api.js
import axios from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores/authStore'
const api = axios.create({
baseURL: 'http://localhost:8000', // Twój backend
})
// Request interceptor dodawanie tokena
api.interceptors.request.use((config) => {
// const token = localStorage.getItem('token')
// if (token) {
const authStore = useAuthStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
})
// Response interceptor obsługa błędów
api.interceptors.response.use(
(response) => response,
(error) => {
const authStore = useAuthStore()
if (error.response && error.response.status === 401) {
authStore.logout()
router.push('/')
// Używamy redirectu bez routera (bo nie zawsze mamy go w tym miejscu)
// window.location.href = '/'
}
return Promise.reject(error)
},
)
export default api

View File

@@ -0,0 +1,11 @@
// src/api/authApi.js
import api from './api'
export const login = async (email, password) => {
const params = new URLSearchParams()
params.append('username', email)
params.append('password', password)
const res = await api.post('/token', params)
return res.data // { access_token: "...", token_type: "bearer" }
}

View File

@@ -0,0 +1,7 @@
// src/api/dataApi.js
import api from './api'
export const getData = async (year, month) => {
const res = await api.get(`/data/${year}/${month}`)
return res.data
}

View File

@@ -0,0 +1,19 @@
import may from '@/assets/may.json'
import june from '@/assets/june.json'
import july from '@/assets/july.json'
export default {
getResponse(month) {
if (!month || typeof month !== 'string' || month.trim() === '' || month === 'lipiec') {
return july
}
if (month === 'czerwiec') {
return june
}
if (month === 'maj') {
return may
}
},
}

View File

@@ -0,0 +1,298 @@
:root {
--background-body: #f4f5f7;
--background-card: #fff;
--background-header: #f8f9fa;
--main-333: #333;
--main-666: #666;
--main-999: #999;
--main-aaa: #aaa;
--main-ccc: #ccc;
--blue-bar: #4a8dff;
--yellow-bar: #ffc107;
--red-bar: #dc3545;
--green-bar: #28a745;
--gray-bar: #e4e1e1;
--badge-workday-main: #007bff;
--badge-workday-light: #007bff;
--badge-satturday-main: #fc9700;
--badge-satturday-light: #ffeebb;
--badge-sunday-main: #dc3545;
--badge-sunday-light: #f8dddf;
--badge-holiday-main: #20a03e;
--badge-holiday-light: #bbf0c8;
}
.green {
color: #28a745;
}
.red {
color: #dc3545;
}
body {
box-sizing: border-box;
font-family: 'Inter', sans-serif;
background-color: var(--background-body);
color: var(--main-333);
margin: 0;
padding: 20px;
}
.dashboard {
display: flex;
flex-direction: column;
gap: 20px;
}
.card-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 20px;
}
.card {
background-color: var(--background-card);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
padding: 20px;
/* flex: 1; */
}
.card h2 {
/* margin: 0 0 10px 0; */
margin: 0;
font-size: 28px;
font-weight: 700;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
color: var(--main-666);
font-size: 14px;
margin-bottom: 10px;
}
.card-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--main-333);
}
.card-header p {
margin: 0;
font-size: 14px;
color: var(--main-999);
}
.blue-bar {
background-color: var(--blue-bar);
}
.yellow-bar {
background-color: var(--yellow-bar);
}
.red-bar {
background-color: var(--red-bar);
}
.green-bar {
background-color: var(--green-bar);
}
.gray-bar {
background-color: var(--gray-bar);
}
.total-hours-main {
display: flex;
align-items: baseline;
gap: 10px;
}
.total-hours-main h1 {
font-size: 36px;
margin: 0;
}
.balance {
display: flex;
align-items: end;
font-size: 18px;
}
.light-text {
color: var(--main-aaa);
margin-left: 8px;
}
.progress-bar {
width: 100%;
display: flex;
height: 12px;
overflow: hidden;
margin: 20px 0;
gap: 3px;
}
.progress {
height: 100%;
border-radius: 6px;
flex-grow: 1;
}
.hours-details {
display: flex;
justify-content: space-between;
}
.hours-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.hours-item p {
margin: 0;
font-weight: 600;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.hiden {
visibility: hidden;
}
.center {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
/* background-color: #bbebeb; */
}
.progress-stripe {
background-image: repeating-linear-gradient(
-45deg,
rgba(255, 255, 255, 0.3),
rgba(255, 255, 255, 0.3) 15px,
transparent 15px,
transparent 25px
);
}
.chart-container {
position: relative;
width: 100%;
height: 200px;
}
.list-header {
position: sticky;
top: 0;
z-index: 10;
display: grid;
padding: 10px;
background-color: var(--background-body);
/* grid-template-columns: repeat(8, 1fr); */
grid-template-columns: 1fr 1fr 30px [start-enter] 1.5fr 30px 1.5fr[end-exit] 0.5fr 1fr 1fr 2fr 2fr 2fr 2fr;
grid-template-areas: 'dayOfMonth dayOfWeek gap enter rem-x exit alert holiday sickday worked overtime hours balance';
text-align: center;
align-items: center;
justify-items: center;
margin-bottom: 5px;
border-radius: 5px;
box-shadow: 0 1px 5px 1px rgba(0, 0, 0, 0.1);
padding: 10px;
}
.list-item {
display: grid;
padding: 10px;
height: 40px;
/* grid-template-columns: repeat(8, 1fr); */
grid-template-columns: 1fr 1fr 30px [start-enter] 1.5fr 30px 1.5fr[end-exit] 0.5fr 1fr 1fr 2fr 2fr 2fr 2fr;
grid-template-areas: 'dayOfMonth dayOfWeek gap enter rem-x exit alert holiday sickday worked overtime hours balance';
justify-items: center;
align-items: center;
background-color: var(--background-card);
border-radius: 5px;
box-shadow: 0 0px 5px 1px rgba(0, 0, 0, 0.05);
padding: 10px;
margin-bottom: 5px;
font-size: 18px;
}
.list-grid-dayOfMonth {
grid-area: dayOfMonth;
}
.list-grid-dayOfWeek {
grid-area: dayOfWeek;
}
.list-grid-enter {
grid-area: enter;
}
.list-grid-remove {
grid-area: rem-x;
}
.list-grid-exit {
grid-area: exit;
}
.list-grid-alert {
grid-area: alert;
}
.list-grid-holiday {
grid-area: holiday;
}
.list-grid-sickday {
grid-area: sickday;
}
.list-grid-worked {
grid-area: worked;
}
.list-grid-overtime {
grid-area: overtime;
}
.list-grid-hours {
grid-area: hours;
}
.list-grid-balance {
grid-area: balance;
}
.badge {
width: 40px;
height: 30px;
display: inline-block;
box-sizing: border-box;
color: var(--badge-workday-main);
border: 2px solid var(--badge-workday-main);
/* background-color: #007bff; */
border-radius: 0.3rem;
text-align: center;
align-content: center;
}
.badge.satturday {
color: var(--badge-satturday-main);
background-color: var(--badge-satturday-light);
border: 2px solid var(--badge-satturday-main);
}
.badge.sunday {
color: var(--badge-sunday-main);
background-color: var(--badge-sunday-light);
border: 2px solid var(--badge-sunday-main);
}
.badge.holiday {
color: var(--badge-holiday-main);
background-color: var(--badge-holiday-light);
border: 2px solid var(--badge-holiday-main);
}

View File

@@ -0,0 +1,134 @@
<template>
<Bar :data="chartData" :options="chartOptions" />
</template>
<script setup>
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
} from 'chart.js'
import { computed } from 'vue'
import utils from '@/utils/utils'
// Rejestrujemy komponenty Chart.js
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
const props = defineProps({
dataset: {
type: Array,
required: true,
},
})
// const labels = props.dataset.map((entry) => entry.day.toString())
// const daysOfWeek = props.dataset.map((entry) => entry.dayOfWeek)
// Dane do wykresu
const chartData = computed(() => {
return {
labels: props.dataset.map((entry) => entry.dayOfMonth.toString()),
datasets: [
{
label: 'Ulrop',
data: props.dataset.map((entry) => (entry.isHolidayLeave ? 8 : 0)),
borderColor: '#ffc107',
backgroundColor: '#ffc10780',
borderWidth: 2,
borderRadius: 5,
borderSkipped: false,
},
{
label: 'Chorobowe',
data: props.dataset.map((entry) => (entry.isSickLeave ? 6.4 : 0)),
borderColor: '#dc3545',
backgroundColor: '#dc354580',
borderWidth: 2,
borderRadius: 5,
borderSkipped: false,
},
{
label: 'Godziny',
data: props.dataset.map((entry) => entry.workedHours),
borderColor: '#4a8dff',
backgroundColor: 'rgba(74, 141, 255, 0.5)',
borderWidth: 2,
borderRadius: 5,
borderSkipped: false,
},
],
}
})
// Opcje wykresu
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: false,
title: false,
},
tooltip: {
bodyFont: {
size: 20,
},
titleFont: {
size: 20,
},
callbacks: {
title: function (tooltipItems) {
return tooltipItems[0].label + ' dzień'
},
label: function (context) {
let label = context.dataset.label || ''
if (context.parsed.y !== null) {
label = label + ' ' + utils.floatHoursToHHMM(context.parsed.y)
}
return label
},
},
},
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
font: {
size: 14,
weight: 'bold',
},
color: function (context) {
const index = context.index
const dayData = props.dataset[index]
if (dayData.dayOfWeek === 'So') return '#fc9700'
if (dayData.dayOfWeek === 'Nd') return '#dc3545'
if (dayData.isPublicHoliday) return '#20a03e'
return '#aaa' // pnpt
},
},
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: '#e0e0e0',
},
ticks: {
color: '#333',
stepSize: 2,
},
},
},
}
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="card">
<div class="card-header">
<p>{{ label }}</p>
<svg-icon type="mdi" :path="icon" :size="22" />
</div>
<h2>{{ data }}</h2>
</div>
</template>
<script setup>
import SvgIcon from '@jamescoyle/vue-icon'
defineProps({
icon: String,
label: String,
data: [String, Number],
})
</script>
<script>
export default {
components: {
SvgIcon,
},
}
</script>

View File

@@ -0,0 +1,222 @@
<template>
<div class="list-item">
<!-- Day of month -->
<span class="list-grid-dayOfMonth">{{ day.dayOfMonth }}</span>
<!-- Day of week -->
<span class="badge list-grid-dayOfWeek" :class="dayBadge(day)">{{ day.dayOfWeek }}</span>
<!-- Edit button -->
<div class="enter-exit center" v-if="!entryT && !exitT">
<button class="button edit-button" @click="addWorkTime">DODAJ</button>
</div>
<div class="list-grid-remove center" v-else>
<button class="button remove-button center" @click="removeWorkTime">
<svg-icon type="mdi" :path="mdiCloseCircleOutline" :size="32" />
</button>
</div>
<!-- Entry time -->
<TimeStepper class="list-grid-enter" v-if="entryT" v-model:time="entryT" />
<!-- Exit time -->
<TimeStepper class="list-grid-exit" v-if="exitT" v-model:time="exitT" />
<!-- Alert -->
<div class="list-grid-alert red center">
<svg-icon v-if="day.alert" type="mdi" :path="mdiAlertCircleOutline" :size="32" />
</div>
<!-- vacation day -->
<span class="list-grid-holiday"
><input
:class="isfreeDay(day)"
type="checkbox"
v-model="day.isHolidayLeave"
:disabled="day.isSickLeave"
@change="updateDay(day)"
/></span>
<!-- sick day -->
<span class="list-grid-sickday"
><input
:class="isfreeDay(day)"
type="checkbox"
v-model="day.isSickLeave"
:disabled="day.isHolidayLeave"
@change="updateDay(day)"
/></span>
<!-- working time -->
<div class="list-grid-worked">
<TimeLabel :time="day.workedHours" :is-visable="day.workedHours > 0" />
</div>
<!-- The balance of hours of that day -->
<div class="list-grid-overtime">
<TimeLabel
:time="day.overtime"
:is-visable="day.overtime !== 0"
:show-icon="true"
:class="textColor(day.overtime)"
/>
</div>
<!-- sum of hours from the beginning of the month -->
<div class="list-grid-hours">
<TimeLabel :time="day.accumulatedHours" :is-visable="isWorkTime" />
</div>
<!-- balance of hours -->
<div class="list-grid-balance">
<TimeLabel
:time="day.balanceHours"
:is-visable="isWorkTime"
:show-icon="true"
:class="textColor(day.balanceHours)"
/>
</div>
</div>
</template>
<script setup>
import TimeStepper from './TimeStepper.vue'
import TimeLabel from './TimeLabel.vue'
import { computed } from 'vue'
import { useAttendanceStore } from '@/stores/attendanceStore'
import SvgIcon from '@jamescoyle/vue-icon'
import { mdiAlertCircleOutline, mdiCloseCircleOutline } from '@mdi/js'
import utils from '@/utils/utils.js'
// const { day } = defineProps({
// day: Object,
// })
const attendanceStore = useAttendanceStore()
const props = defineProps({ index: Number })
const day = computed(() => attendanceStore.days[props.index])
const isWorkTime = computed(() => Boolean(day.value.workedHours || utils.isValidWorkDay(day.value)))
const entryT = computed({
get() {
return day.value.entryTime
},
set(newVal) {
// console.log('entryT newVal: ', newVal, 'typeof:', typeof newVal)
if (typeof newVal === 'number' && Number.isNaN(newVal)) {
console.log('entryT newVal NaN')
return
}
console.log('neVal: ', newVal)
const workTime = utils.calculateWorkedTime(newVal, day.value.exitTime)
if (workTime > 0) {
day.value.entryTime = newVal
attendanceStore.updateDay(day.value)
}
},
})
const exitT = computed({
get() {
return day.value.exitTime
},
set(newVal) {
if (typeof newVal === 'number' && Number.isNaN(newVal)) {
console.log('exitT newVal NaN')
return
}
console.log('neVal: ', newVal)
const workTime = utils.calculateWorkedTime(day.value.entryTime, newVal)
if (workTime > 0) {
day.value.exitTime = newVal
attendanceStore.updateDay(day.value)
}
},
})
const updateDay = (currentday) => {
attendanceStore.updateDay(currentday)
}
const dayBadge = (currentday) => ({
satturday: currentday.dayOfWeek === 'So',
sunday: currentday.dayOfWeek === 'Nd',
holiday: currentday.isPublicHoliday,
})
const textColor = (hours) => ({
green: hours > 0,
red: hours < 0,
})
const isfreeDay = (currentday) => ({
hiden: !utils.isValidWorkDay(currentday),
})
const addWorkTime = () => {
console.log('click add')
day.value.entryTime = '07:00'
day.value.exitTime = '15:15'
attendanceStore.updateDay(day.value)
}
const removeWorkTime = () => {
console.log('click remove')
day.value.entryTime = ''
day.value.exitTime = ''
attendanceStore.updateDay(day.value)
}
</script>
<style scoped>
.enter-exit {
grid-column-start: start-enter;
grid-column-end: end-exit;
/* background-color: blueviolet; */
width: 100%;
height: 100%;
}
.edit-button {
width: 200px;
font-size: 16px;
font-weight: 600;
padding: 5px;
color: var(--gray-bar);
background-color: transparent;
border: solid var(--gray-bar);
border-width: 2px;
border-radius: 0.3rem;
transition:
color 0.3s ease,
border-color 0.3s ease,
background-color 0.3s ease;
}
.remove-button {
/* width: 30px; */
/* height: 30px; */
/* font-size: 16px; */
/* font-weight: 600; */
/* padding: auto; */
color: var(--gray-bar);
background-color: transparent;
border: none;
/* border-width: 2px; */
/* border-radius: 50%; */
transition:
color 0.3s ease,
border-color 0.3s ease,
background-color 0.3s ease;
}
.button:hover {
color: var(--main-ccc);
border-color: var(--main-ccc);
}
.margin-top {
margin-top: 30px;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="card">
<div class="card-header">
<h3>Twoje godziny</h3>
<MonthMenu />
</div>
<div class="total-hours-main">
<h1>{{ formatHours(sumOfHours) }}</h1>
<div :class="['balance', props.overtime_hours >= 0 ? 'green' : 'red']">
<span class="material-symbols-outlined">
{{ props.overtime_hours >= 0 ? 'arrow_upward' : 'arrow_downward' }}
</span>
{{ formatHours(Math.abs(props.overtime_hours)) }}
</div>
<div class="user-name">
<h3 class="user-name">Marcin Nowak</h3>
<span @click="logoutUser">WYLOGUJ</span>
</div>
</div>
<p class="light-text">
Jest to suma godzin wypracowanych, godzin z urlopu (8h za dzień) i godzin z chorobowego (06:24
za dzień, 80% z 8h = 6.4h).
</p>
<div class="progress-bar">
<div class="progress blue-bar" :style="{ width: blueWidth + '%' }"></div>
<div class="progress yellow-bar" :style="{ width: yellowWidth + '%' }"></div>
<div
class="progress red-bar"
:style="{ width: redWidth + '%' }"
v-if="props.sick_hours > 0"
></div>
<div class="progress gray-bar progress-stripe" :style="{ width: grayWidth + '%' }"></div>
</div>
<div class="hours-details">
<div class="hours-item">
<span class="dot blue-bar"></span>Godziny wypracowane
<p>{{ formatHours(props.working_hours) }}</p>
</div>
<div class="hours-item">
<span class="dot yellow-bar"></span>Godziny z urlopu
<p>{{ formatHours(props.holiday_hours) }}</p>
</div>
<div class="hours-item">
<span class="dot red-bar"></span>Godziny z chorobowego
<p>{{ formatHours(props.sick_hours) }}</p>
</div>
<div class="hours-item">
<span class="dot gray-bar"></span>Pozostało godzin
<p>{{ formatHours(props.to_go_hours) }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/authStore'
import { useRouter } from 'vue-router'
import MonthMenu from './MonthMenu.vue'
const authStore = useAuthStore()
const router = useRouter()
const props = defineProps({
sum_of_hours: { type: Number, default: 0 },
overtime_hours: { type: Number, default: 0 },
working_hours: { type: Number, default: 0 },
holiday_hours: { type: Number, default: 0 },
sick_hours: { type: Number, default: 0 },
to_go_hours: { type: Number, default: 0 },
})
const sumOfHours = computed(() => {
return props.working_hours + props.holiday_hours + props.sick_hours
})
const total = computed(
() => props.working_hours + props.holiday_hours + props.sick_hours + props.to_go_hours,
)
const blueWidth = computed(() =>
total.value ? ((props.working_hours / total.value) * 100).toFixed(2) : 0,
)
const yellowWidth = computed(() =>
total.value ? ((props.holiday_hours / total.value) * 100).toFixed(2) : 0,
)
const redWidth = computed(() =>
total.value ? ((props.sick_hours / total.value) * 100).toFixed(2) : 0,
)
const grayWidth = computed(() =>
total.value ? ((props.to_go_hours / total.value) * 100).toFixed(2) : 0,
)
// Zamienia liczbę godzin dziesiętnych na format "HH:MM"
function formatHours(value) {
const totalMinutes = Math.round(value * 60)
const hours = Math.floor(totalMinutes / 60)
.toString()
.padStart(2, '0')
const minutes = (totalMinutes % 60).toString().padStart(2, '0')
return `${hours}:${minutes}`
}
const logoutUser = () => {
authStore.logout()
router.push('/')
}
</script>
<style scoped>
/* .dropdown {
border: solid;
} */
.user-name {
margin-left: auto;
margin-right: 10px;
display: flex;
flex-direction: column;
align-items: end;
}
.user-name h3 {
margin: 0;
/* color: var(--main-333); */
}
.user-name span {
color: var(--main-aaa);
cursor: pointer;
}
.user-name span:hover {
color: var(--main-333);
}
</style>
<!DOCTYPE html>

View File

@@ -0,0 +1,151 @@
<template>
<div class="dropdown" :class="{ show: showMenu }" ref="dropdownRef">
<button class="dropdown-button container" @click="showMenu = !showMenu">
{{ utils.getMonthName(attendanceStore.month) }}
<span class="material-symbols-outlined">expand_more</span>
</button>
<div class="dropdown-content">
<button
v-for="option in attendanceStore.lastThreeMonth"
:key="option"
@click="select(option)"
>
<span class="button-text">
<span>{{ utils.getMonthName(option) }}</span>
<span>{{ attendanceStore.year }}</span>
</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useAttendanceStore } from '@/stores/attendanceStore'
import { getData } from '@/api/dataApi'
import utils from '@/utils/utils'
const attendanceStore = useAttendanceStore()
const showMenu = ref(false)
const dropdownRef = ref(null)
const loading = ref(false)
const select = async (newMonth) => {
console.log('newMonth: ', newMonth)
showMenu.value = false
loading.value = true
const year = attendanceStore.year
try {
const response = await getData(year, newMonth) // przykładowa data
attendanceStore.loadFromResponse(response)
} catch (error) {
error.value = 'Błąd pobierania danych.'
} finally {
loading.value = false
}
// const response = MockApi.getResponse(newMonth)
// attendanceStore.loadFromResponse(response)
}
const handleClickOutside = (event) => {
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
showMenu.value = false
}
}
// onMounted(async () => {
// loading.value = true
// })
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.container {
display: flex;
align-items: center;
justify-content: space-between;
/* gap: 20px; */
}
/* Styl kontenera menu */
.dropdown {
position: relative;
display: inline-block;
/* font-family: sans-serif; */
}
/* Przycisk główny */
.dropdown-button {
width: 160px;
background-color: var(--background-card);
color: var(--main-666);
padding: 2px 5px 2px 15px;
font-size: 16px;
box-shadow: 0 0px 8px rgba(0, 0, 0, 0.2);
border: none;
border-radius: 10px;
cursor: pointer;
}
/* Lista rozwijana */
.dropdown-content {
position: absolute;
background-color: white;
min-width: 160px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
border-radius: 10px;
margin-top: 5px;
z-index: 1;
right: 0;
transform: translateY(-10px);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
transform 0.2s ease,
opacity 0.2s ease,
visibility 0.2s ease;
}
.dropdown.show .dropdown-content {
transform: translateY(0);
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.dropdown-content button {
color: var(--main-666);
font-size: 16px;
background: none;
border: none;
padding: 10px 14px;
width: 100%;
text-align: left;
cursor: pointer;
transition:
color 0.3s ease,
border-color 0.3s ease,
background-color 0.3s ease;
}
.dropdown-content button:hover {
background-color: #f1f1f1;
}
.button-text {
display: flex;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="center" v-if="isVisable">
<span v-if="showIcon" class="material-symbols-outlined" :class="time != 0 ? '' : 'hiden'">
{{ time >= 0 ? 'arrow_upward' : 'arrow_downward' }}
</span>
<span>{{ getHours(time) }}</span>
<span>:</span>
<span>{{ getMinutes(time) }}</span>
</div>
</template>
<script setup>
import utils from '@/utils/utils'
defineProps({
time: Number,
isVisable: Boolean,
showIcon: {
type: Boolean,
default: false,
},
})
function getHours(time) {
const formatedText = utils.floatHoursToHHMM(time)
const endIndex = formatedText.indexOf(':')
const startIndex = formatedText.charAt(0) === '-' ? 1 : 0
return formatedText.slice(startIndex, endIndex)
}
function getMinutes(time) {
const formatedText = utils.floatHoursToHHMM(time)
const startIndex = formatedText.indexOf(':')
return formatedText.slice(startIndex + 1, startIndex + 3)
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,290 @@
<template>
<div class="container">
<div class="wrapper hours">
<button
class="btn plus left btn_up"
@mousedown="onIncrementClick('hour')"
@mouseleave="stopInterval"
@mouseup="stopInterval"
></button>
<span class="hours" v-if="paddedHour != 'NaN'">{{ paddedHour }}</span>
<button
class="btn minus left btn_down"
@mousedown="onDecrementClick('hour')"
@mouseleave="stopInterval"
@mouseup="stopInterval"
></button>
</div>
<span>:</span>
<div class="wrapper minutes">
<button
class="btn plus right btn_up"
@mousedown="onIncrementClick('minute')"
@mouseleave="stopInterval"
@mouseup="stopInterval"
></button>
<span class="minutes" v-if="paddedHour != 'NaN'">{{ paddedMinute }}</span>
<button
class="btn minus right btn_down"
@mousedown="onDecrementClick('minute')"
@mouseleave="stopInterval"
@mouseup="stopInterval"
></button>
</div>
</div>
</template>
<script setup>
import { computed, ref, nextTick } from 'vue'
// Umożliwienie v-model:time
const props = defineProps({
time: {
type: String,
required: true,
},
})
const earliestHour = 6
const latestHour = 20
const interval = ref(null)
const timeout = ref(null)
const emit = defineEmits(['update:time'])
// computed get/set: hour
const hour = computed({
get() {
return parseInt(props.time.split(':')[0], 10)
},
set(newVal) {
const [, m] = props.time.split(':')
const hourVal = Math.min(latestHour, Math.max(earliestHour, newVal))
// console.log('Hour computed. h:', hourVal, 'm: ', m)
if (typeof newVal !== 'number' || isNaN(newVal)) {
console.log('Hour emit NaN')
emit('update:time', NaN)
} else {
emit('update:time', `${String(hourVal).padStart(2, '0')}:${m}`)
}
},
})
// computed get/set: minute
const minute = computed({
get() {
return parseInt(props.time.split(':')[1], 10)
},
set(newVal) {
let [h] = props.time.split(':')
let minuteVal = Math.min(59, Math.max(0, newVal))
// console.log('Minute computed. h:', h, 'm: ', minuteVal)
if (typeof newVal !== 'number' || isNaN(newVal)) {
console.log('Minute emit NaN')
emit('update:time', NaN)
} else {
emit('update:time', `${h}:${String(minuteVal).padStart(2, '0')}`)
}
},
})
// Formatowanie z wiodącym zerem
const paddedHour = computed(() => String(hour.value).padStart(2, '0'))
const paddedMinute = computed(() => String(minute.value).padStart(2, '0'))
// const waitForMinuteToUpdate = async (expectedValue) => {
// let maxTries = latestHour
// while (minute.value !== expectedValue && maxTries > 0) {
// await nextTick() // poczekaj na re-render
// maxTries--
// }
// return maxTries
// }
const incrementValue = async (type) => {
if (type === 'minute' && !isNaN(minute.value)) {
let newMinute = (minute.value + 1) % 60
if (hour.value === latestHour && newMinute === 0) {
return
}
minute.value = newMinute
if (newMinute === 0) {
type = 'hour'
await nextTick()
}
}
if (type == 'hour' && !isNaN(hour.value)) {
let newHour = hour.value + 1
hour.value = Math.min(latestHour, Math.max(earliestHour, newHour))
}
}
const decrementValue = async (type) => {
if (type === 'minute' && !isNaN(minute.value)) {
let newMinute = (minute.value + 59) % 60
if (hour.value === earliestHour && newMinute === 59) {
return
}
minute.value = newMinute
if (newMinute === 59) {
type = 'hour'
await nextTick()
}
}
if (type == 'hour' && !isNaN(hour.value)) {
let newHour = hour.value - 1
hour.value = Math.min(latestHour, Math.max(earliestHour, newHour))
}
}
const onIncrementClick = (type) => {
incrementValue(type)
timeout.value = setTimeout(() => {
interval.value = setInterval(() => {
incrementValue(type)
}, 100)
}, 500)
}
const onDecrementClick = (type) => {
decrementValue(type)
timeout.value = setTimeout(() => {
interval.value = setInterval(() => {
decrementValue(type)
}, 100)
}, 500)
}
const stopInterval = () => {
clearTimeout(timeout.value)
clearInterval(interval.value)
timeout.value = null
interval.value = null
}
</script>
<style scoped>
.container {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
/* background-color: #bbebeb; */
}
.wrapper {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
/* background-color: #f9f9f9; */
overflow: visible;
}
.hours {
justify-content: end;
}
.minutes {
justify-content: start;
}
/* przyciski */
.btn {
position: absolute;
/* left: 50%; */
transform: translateX(-50%) scale(0.8);
opacity: 0;
transition:
opacity 0.3s ease,
transform 0.3s ease,
background-color 0.3s ease,
border-color 0.1s ease; /* <- to odpowiada za border */
width: 24px;
height: 24px;
/* background-color: #dbd8d8; */
/* border: 1px solid #278bf5; */
border: none;
cursor: pointer;
z-index: 10;
}
.btn_up {
/* border-width: 2px 2px 2px 2px; */
border-radius: 50% 50% 0 0;
}
.btn_down {
/* border-width: 2px 2px 2px 2px; */
border-radius: 0 0 50% 50%;
}
/* pokazanie przy hoverze */
.wrapper:hover .btn {
opacity: 1;
transform: translateX(-50%) scale(1);
}
.btn.minus {
bottom: -5px;
}
.btn.left {
left: 5px;
}
.btn.plus {
top: -5px;
}
.btn.right {
left: 36px;
}
.btn::before,
.btn::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
border: solid #bdbdbd;
border-width: 0 2px 2px 0;
display: inline-block;
padding: 0;
}
.btn.plus::before {
top: 60%;
left: 50%;
transform: translate(-50%, -50%) rotate(-135deg); /* strzałka do góry */
}
.btn.minus::before {
top: 40%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg); /* strzałka w dół */
}
/* usuń .btn::after całkiem niepotrzebne */
.btn.plus::after,
.btn.minus::after {
display: none;
}
/* .btn.plus:hover {
border: solid #3ff527;
background-color: #3ff527;
} */
</style>

14
frontend/src/main.js Normal file
View File

@@ -0,0 +1,14 @@
import './assets/main.css'
// import './assets/tailwind.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import LoginView from '../views/LoginView.vue'
import DashboardView from '../views/DashboardView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'Login',
component: LoginView,
},
{
path: '/dashboard',
name: 'Dashboard',
component: DashboardView,
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
redirect: '/login',
},
],
})
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated()) {
return { name: 'Login' }
}
})
export default router

View File

@@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import utils from '@/utils/utils.js'
export const useAttendanceStore = defineStore('attendanceStore', () => {
const employee = ref('')
const year = ref(null)
const month = ref('')
const daysInMonth = ref(0)
const workingDays = ref(0)
const workingHours = ref(0)
const days = ref([])
// const lastThreeMonth = ['lipiec', 'czerwiec', 'maj']
const lastThreeMonth = utils.getLastMonths(new Date().getMonth() + 1, 5)
const sumOfHours = computed(() => days.value[days.value.length - 1]?.accumulatedHours || 0)
const workedHours = computed(() => sumOfHours.value - holidayHours.value - sickHours.value)
const overtimeHours = computed(() => days.value[days.value.length - 1]?.balanceHours || 0)
const holidayHours = computed(() => holidayCount.value * 8)
const sickHours = computed(() => sickCount.value * 6.4)
const toGoHours = computed(() => leaveDayCount.value * 8)
const sickCount = computed(() => days.value.filter((d) => d.isSickLeave)?.length || 0)
const holidayCount = computed(() => days.value.filter((d) => d.isHolidayLeave)?.length || 0)
const leaveDayCount = computed(() => {
console.log('wywylanie leaveFayCount')
return (
workingDays.value -
days.value.filter(
(day) =>
utils.isValidWorkDay(day) &&
(day.workedHours > 0 || day.isSickLeave || day.isHolidayLeave),
).length
)
})
function updateDay(day) {
utils.calculateMonthFromDay(day, days.value)
}
function loadFromResponse(response) {
employee.value = response.employee
year.value = response.year
month.value = response.month
daysInMonth.value = response.days_in_month
workingDays.value = response.working_days
workingHours.value = workingDays.value * 8
let workingDaysCount = 0
let accumulated = 0
let balance = 0
days.value = []
for (let d = 1; d <= daysInMonth.value; d++) {
const dateStr = `${year.value}-${month.value.toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`
const dayData = response.days.find((day) => day.check_in.startsWith(dateStr)) || {}
const isPublicHoliday = response.public_holidays.some((h) => h.date === dateStr)
const alert = dayData.attendance_reason_ids?.length > 0
const day = {
dayOfMonth: d,
dayOfWeek: utils.getDayOfWeek(dateStr),
entryTime: utils.extractTimeFromDateString(dayData.check_in),
exitTime: utils.extractTimeFromDateString(dayData.check_out),
isSickLeave: false,
isHolidayLeave: false,
isPublicHoliday: isPublicHoliday,
workedHours: 0,
overtime: 0,
accumulatedHours: 0,
balanceHours: 0,
alert: alert,
}
if (utils.isValidWorkDay(day)) {
workingDaysCount++
}
accumulated += utils.calculateDay(day)
balance = parseFloat((accumulated - workingDaysCount * 8).toFixed(9))
day.accumulatedHours = parseFloat(accumulated.toFixed(9))
day.balanceHours = balance
days.value.push(day)
}
}
return {
// state
employee,
year,
month,
daysInMonth,
workingDays,
workingHours,
days,
sickCount,
holidayCount,
leaveDayCount,
sumOfHours,
workedHours,
overtimeHours,
holidayHours,
sickHours,
toGoHours,
lastThreeMonth,
// actions
loadFromResponse,
updateDay,
}
})

View File

@@ -0,0 +1,23 @@
// src/stores/authStore.js
import { defineStore } from 'pinia'
import { login } from '@/api/authApi'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || null,
}),
actions: {
async loginUser(email, password) {
const data = await login(email, password)
this.token = data.access_token
localStorage.setItem('token', this.token)
},
logout() {
this.token = null
localStorage.removeItem('token')
},
isAuthenticated() {
return !!this.token
},
},
})

234
frontend/src/utils/utils.js Normal file
View File

@@ -0,0 +1,234 @@
const polishMonths = {
styczeń: 1,
luty: 2,
marzec: 3,
kwiecień: 4,
maj: 5,
czerwiec: 6,
lipiec: 7,
sierpień: 8,
wrzesień: 9,
październik: 10,
listopad: 11,
grudzień: 12,
}
const months = [
'styczeń',
'luty',
'marzec',
'kwiecień',
'maj',
'czerwiec',
'lipiec',
'sierpień',
'wrzesień',
'październik',
'listopad',
'grudzień',
]
const daysOfWeek = ['Nd', 'Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'So']
// function getLastThreeMonth(monthName) {
// const months = Object.keys(polishMonths) // ["styczeń", "luty", ..., "grudzień"]
// const index = months.indexOf(monthName)
// if (index === -1) return [] // jeśli nie znaleziono miesiąca
// return [months[(index - 2 + 12) % 12], months[(index - 1 + 12) % 12], months[index]]
// }
// function getLastThreeMonth(monthName) {
// const index = months.indexOf(monthName)
// if (index === -1) return [] // jeśli nie znaleziono miesiąca
// return [months[(index - 2 + 12) % 12], months[(index - 1 + 12) % 12], months[index]]
// }
function getLastMonths(startMonth, count) {
const result = []
for (let i = 0; i < count; i++) {
// Obliczamy numer miesiąca (112), uwzględniając cofanie się w roku
let month = ((startMonth - i - 1 + 12) % 12) + 1
result.push(month)
}
return result
}
function getLastThreeMonth(month) {
if (!month || typeof month !== 'number' || month < 1 || month > 12) {
return null
}
return [((month - 3 + 12) % 12) + 1, ((month - 2 + 12) % 12) + 1, month]
}
function getMonthName(month) {
if (!month || typeof month !== 'number' || month < 1 || month > 12) {
return null
}
return months[month - 1]
}
function getMonthNumber(monthName) {
const monthNum = polishMonths[monthName.toLowerCase()]
if (!monthNum) {
console.error('❌ Unknown month:', monthName)
return null
}
return String(monthNum).padStart(2, '0')
}
function getDayOfWeek(dateStr) {
const date = new Date(dateStr)
return daysOfWeek[date.getDay()]
}
function extractTimeFromDateString(isoString) {
if (!isoString || typeof isoString !== 'string') return ''
const date = new Date(isoString + 'Z')
if (isNaN(date.getTime())) return ''
return date.toTimeString().slice(0, 5) // 'HH:MM:SS'
}
function isValidWorkDay(day) {
if (!day || typeof day !== 'object') return false
if (!('dayOfWeek' in day) || !('isPublicHoliday' in day)) return false
return day.dayOfWeek !== 'So' && day.dayOfWeek !== 'Nd' && !day.isPublicHoliday
}
function calculateWorkedTime(entryTime, exitTime) {
if (!entryTime || !exitTime || typeof entryTime !== 'string' || typeof exitTime !== 'string') {
return 0
}
const [eh, em] = entryTime.split(':').map(Number)
const [xh, xm] = exitTime.split(':').map(Number)
if ([eh, em, xh, xm].some((v) => typeof v !== 'number' || isNaN(v))) {
return 0
}
const start = eh * 60 + em
const end = xh * 60 + xm
let worked = (end - start) / 60
return parseFloat(worked.toFixed(9))
}
function floatHoursToHHMM(decimalHours) {
if (typeof decimalHours !== 'number' || isNaN(decimalHours)) return ''
// if (decimalHours === 0) return ''
const negative = decimalHours < 0
const abs = Math.abs(decimalHours)
const m_total = Math.round(abs * 60)
const h = Math.floor(m_total / 60)
const m = m_total % 60
const hh = String(h).padStart(2, '0')
const mm = String(m).padStart(2, '0')
return `${negative ? '-' : ''}${hh}:${mm}`
}
function calculateDay(day) {
let exitTime = day.exitTime
if (!day.exitTime || typeof day.exitTime !== 'string' || day.exitTime.trim() === '') {
// const now = new Date().toLocaleString('pl-PL', { timeZone: 'Europe/Warsaw' })
const now = new Date()
const hh = String(now.getHours()).padStart(2, '0')
const mm = String(now.getMinutes()).padStart(2, '0')
const ss = String(now.getSeconds()).padStart(2, '0')
exitTime = `${hh}:${mm}:${ss}`
}
if (!day.entryTime || typeof day.entryTime !== 'string' || day.entryTime.trim() === '') {
day.workedHours = 0
} else {
day.workedHours = calculateWorkedTime(day.entryTime, exitTime) - 0.25
day.workedHours = parseFloat(day.workedHours.toFixed(9))
day.exitTime = exitTime
}
if (isValidWorkDay(day) && !day.isHolidayLeave && !day.isSickLeave) {
day.overtime = day.workedHours - 8
} else {
day.overtime = day.workedHours
}
day.overtime = parseFloat(day.overtime.toFixed(9))
let sumHours = day.workedHours
if (day.isHolidayLeave) {
sumHours += 8
}
if (day.isSickLeave) {
sumHours += 6.4
}
return parseFloat(sumHours.toFixed(9))
}
function calculateMonth(days) {
let accumulated = 0
let workingDaysCount = 0
let balance = 0
for (let i = 0; i < days.length; i++) {
const day = days[i]
const workedHours = calculateDay(day)
accumulated += workedHours
if (isValidWorkDay(day)) {
workingDaysCount++
}
balance = parseFloat((accumulated - workingDaysCount * 8).toFixed(9))
day.accumulatedHours = parseFloat(accumulated.toFixed(9))
day.balanceHours = balance
}
}
function calculateMonthFromDay(startDay, days) {
let startIndex = days.indexOf(startDay)
if (startIndex === -1) return
let accumulated = 0
let workingDaysCount = 0
let balance = 0
if (startIndex > 0) {
const previousDay = days[startIndex - 1]
accumulated = previousDay.accumulatedHours || 0
balance = previousDay.balanceHours || 0
workingDaysCount = days.slice(0, startIndex).filter(isValidWorkDay).length
}
for (let i = startIndex; i < days.length; i++) {
const day = days[i]
const workedHours = calculateDay(day)
accumulated += workedHours
if (isValidWorkDay(day)) {
workingDaysCount++
}
balance = parseFloat((accumulated - workingDaysCount * 8).toFixed(9))
day.accumulatedHours = parseFloat(accumulated.toFixed(9))
day.balanceHours = balance
}
}
export default {
getDayOfWeek,
extractTimeFromDateString,
isValidWorkDay,
calculateWorkedTime,
floatHoursToHHMM,
calculateDay,
calculateMonth,
calculateMonthFromDay,
getLastThreeMonth,
getLastMonths,
getMonthNumber,
getMonthName,
daysOfWeek,
}

View File

@@ -0,0 +1,95 @@
<template>
<div class="dashboard">
<div class="card-row">
<CardTop :icon="mdiCalendar" label="Dni pracujących" :data="attendanceStore.workingDays" />
<CardTop :icon="mdiTent" label="Dni urlopowych" :data="attendanceStore.holidayCount" />
<CardTop :icon="mdiNeedle" label="Dni chorobowych" :data="attendanceStore.sickCount" />
<CardTop
:icon="mdiTimerAlertOutline"
label="Dni nieobecnych"
:data="attendanceStore.leaveDayCount"
/>
<CardTop
:icon="mdiClockOutline"
label="Godzin w miesiącu"
:data="attendanceStore.workingHours"
/>
</div>
<HoursSummary v-bind="summary" />
<div class="card">
<div class="card-header">
<h3>Wykres godzin</h3>
</div>
<div class="chart-container">
<BarChart v-if="attendanceStore.days.length > 0" :dataset="attendanceStore.days" />
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Tabela godzin</h3>
</div>
<div class="list-header">
<span class="list-grid-dayOfMonth">Dzień</span>
<span class="list-grid-dayOfWeek">Dzień tygodnia</span>
<span class="list-grid-enter">Wejscie</span>
<span class="list-grid-exit">Wyjscie</span>
<span class="list-grid-alert">Alert</span>
<span class="list-grid-holiday">Urlop</span>
<span class="list-grid-sickday">Chorobowe</span>
<span class="list-grid-worked">Czas pracy</span>
<span class="list-grid-overtime">Bilans dzienny</span>
<span class="list-grid-hours">Suma godzin</span>
<span class="list-grid-balance">Bilans ogólny</span>
</div>
<div class="list-body">
<DayRow v-for="(_, index) in attendanceStore.days" :key="index" :index="index" />
</div>
</div>
</div>
<!-- </div> -->
</template>
<script setup>
import { onMounted, computed, ref } from 'vue'
import { useAttendanceStore } from '@/stores/attendanceStore'
import { mdiCalendar, mdiTent, mdiNeedle, mdiClockOutline, mdiTimerAlertOutline } from '@mdi/js'
import { getData } from '@/api/dataApi'
import CardTop from '@/components/CardTop.vue'
import HoursSummary from '@/components/HoursSummary.vue'
import BarChart from '@/components/BarChart.vue'
import DayRow from '@/components/DayRow.vue'
// import MockApi from '@/api/mock_response.js'
const attendanceStore = useAttendanceStore()
const loading = ref(false)
const error = ref('')
const summary = computed(() => ({
sum_of_hours: attendanceStore.sumOfHours,
overtime_hours: attendanceStore.overtimeHours,
working_hours: attendanceStore.workedHours,
holiday_hours: attendanceStore.holidayHours,
sick_hours: attendanceStore.sickHours,
to_go_hours: attendanceStore.toGoHours,
}))
const input = ref('')
onMounted(async () => {
loading.value = true
const currentDate = new Date()
try {
const response = await getData(currentDate.getFullYear(), currentDate.getMonth())
input.value = response
attendanceStore.loadFromResponse(response)
} catch (err) {
error.value = err
} finally {
loading.value = false
}
})
</script>
<style scoped></style>

View File

@@ -0,0 +1,220 @@
<template>
<div class="flex-container">
<div class="card card-size">
<div class="left-side">
<div class="login-container">
<h1>Logowanie</h1>
<form @submit.prevent="handleLogin">
<!-- Email -->
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" v-model="email" placeholder="Podaj email" />
<span class="error" :class="{ hiden: !emailError }">
{{ emailError ? emailError : 'error' }}
</span>
</div>
<!-- Password -->
<div class="form-group">
<label for="password">Hasło</label>
<input id="password" type="password" v-model="password" placeholder="Podaj hasło" />
<div :class="{ hiden: !loginError }" class="error">{{ loginError }}</div>
</div>
<!-- Backend error -->
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Logowanie...' : 'Zaloguj się' }}
</button>
</form>
</div>
</div>
<div class="right-side">
<h2>KALKULATOR GODZIN</h2>
<h3>by Kaczka</h3>
<p>W zakładce obok należy podać login i hasło do serwisu odoo naszej firmy.</p>
<p>Po udanym logowaniu, otworzy się kalkulator godzin.</p>
<p>W prawym rogu, będzie można wybrać miesiąc, który Ciebie interesuje.</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const emailError = ref('')
const loginError = ref('')
const errorMessage = ref('')
const loading = ref(false)
const validateEmail = (emailValue) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return regex.test(emailValue)
}
const handleLogin = async () => {
emailError.value = ''
loginError.value = ''
// Lokalna walidacja
if (!validateEmail(email.value)) {
emailError.value = 'Podaj prawidłowy adres e-mail.'
return
}
if (!password.value) {
loginError.value = 'Hasło jest wymagane.'
return
}
errorMessage.value = ''
loading.value = true
try {
await authStore.loginUser(email.value, password.value)
router.push('/dashboard')
} catch (err) {
errorMessage.value = err.response?.data?.detail || 'Błąd logowania.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.flex-container {
display: flex;
justify-content: center; /* poziomo */
align-items: center; /* pionowo */
height: 100vh;
}
.card-size {
width: 50%;
/* height: 50%; */
display: flex;
padding: 0;
border-radius: 20px;
overflow: hidden;
}
.left-side {
flex: 1;
display: flex;
justify-content: center; /* poziomo */
align-items: center; /* pionowo */
box-shadow: 10px 0px 20px -5px rgba(88, 88, 88, 0.4);
}
.right-side {
/* background-color: #9ee3ec; */
/* border-left: 1px solid var(--main-ccc); */
flex: 1;
display: flex;
justify-content: center; /* poziomo */
align-items: center; /* pionowo */
flex-direction: column;
}
.login-container {
/* max-width: 400px; */
/* margin: auto; */
/* background-color: #f7ebeb; */
/* width: 80%; */
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0px;
}
.error {
color: red;
font-size: 0.9em;
margin-top: 20px;
padding-left: 10px;
}
.form-group {
margin-bottom: 2em;
display: flex;
flex-direction: column;
}
label {
margin-bottom: 1em;
}
input {
box-sizing: border-box;
width: 300px;
font-size: 16px;
background: none;
border: 0;
/* border: 1px solid #c4c4c4; */
border-radius: 20px;
box-shadow: 0px 10px 20px 5px rgba(88, 88, 88, 0.4);
color: #665a5a;
outline: none;
transition: 250ms all ease-in;
padding: 10px;
}
form {
display: flex;
flex-direction: column;
align-items: center;
}
button {
margin-top: 20px;
box-sizing: border-box;
border: 0;
width: 80%;
height: 60px;
border-radius: 30px;
}
.right-side p {
padding: 0 40px;
}
</style>
<!-- <template>
<div class="flex-container">
<div class="card card-size">
<div class="left-side">
<h2>LOGIN</h2>
</div>
<div class="right-side">
<h2>info</h2>
</div>
</div>
</div>
</template>
<script></script>
<style scoped>
.flex-container {
display: flex;
justify-content: center; /* poziomo */
align-items: center; /* pionowo */
height: 100vh;
}
.card-size {
width: 50%;
height: 50%;
display: flex;
/* padding: 0; */
}
.left-side {
flex: 1;
}
.right-side {
/* background-color: var(--main-ccc); */
border-left: 1px solid var(--main-ccc);
flex: 1;
}
</style> -->

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})