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

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv
__pycache__
.env
test.http

175
backend/main.py Normal file
View File

@@ -0,0 +1,175 @@
from datetime import date, datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
import jwt
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel
from dotenv import load_dotenv
import os
import logging
import requests
import json
import numpy as np
import holidays
import calendar
from odoo_api.client import OdooAPIClient
# to get a string like this run:
# openssl rand -hex 32
load_dotenv()
SECRET_KEY = os.getenv('SECRET_KEY')
ALGORITHM = os.getenv('ALGORITHM')
ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES')
ODDO_URL = os.getenv('ODDO_URL')
DB_NAME = os.getenv('DB_NAME')
origins_str = os.getenv('ORIGINS', '')
origins = [origin.strip() for origin in origins_str.split(',') if origin.strip()]
class Token(BaseModel):
access_token: str
token_type: str
class User(BaseModel):
uid: str
full_name: str | None = None
email: str | None = None
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
logger = logging.getLogger("uvicorn")
OdooClient = OdooAPIClient(ODDO_URL, DB_NAME, SECRET_KEY, ALGORITHM)
app = FastAPI()
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
logger.info(f"origins: {origins}")
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
uid = payload.get("uid")
email = payload.get("sub")
full_name = payload.get("full_name")
if uid is None or email is None:
raise credentials_exception
return User(uid=uid, email=email, full_name=full_name)
except InvalidTokenError:
raise credentials_exception
def odoo_api_login(username: str, password: str) -> User | None:
result = OdooClient.login(username, password)
if result.get('uid'):
return User(
uid=str(result.get('uid')),
full_name=result.get('name'),
email=result.get('username')
)
return None
def get_polish_holidays(year: int, month: int):
pl_holidays = holidays.Poland(years=year)
# Filtrowanie po miesiącu
holidays_list = [
{"date": date.strftime("%Y-%m-%d"), "name": name}
for date, name in sorted(pl_holidays.items())
if date.month == month
]
return holidays_list
def create_response(year: int, month: int, days: list) -> dict:
employee = days[0]["employee_id"][1]
days_in_month = calendar.monthrange(year, month)[1]
holidays = get_polish_holidays(year, month)
start = date(year, month, 1)
end = date(year, month, days_in_month) + timedelta(days=1)
# 4. Wyliczenie dni roboczych:
holiday_dates = [h['date'] for h in holidays]
working_days = int(np.busday_count(start.isoformat(), end.isoformat(), holidays=holiday_dates))
return {
"year": year,
"month": month,
"days_in_month": days_in_month,
"employee": employee,
"working_days": working_days,
"public_holidays": holidays,
"days": days
}
@app.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
user = odoo_api_login(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
logger.info(f"User '{user.uid}' logged in successfully.")
logger.info(
f"User '{user.uid}, {user.full_name}, {user.email}' logged in successfully.")
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.email, "uid": user.uid, "full_name": user.full_name},
expires_delta=access_token_expires
)
return Token(access_token=access_token, token_type="bearer")
@app.get("/data/{year}/{month}")
async def read_own_items(
year: int,
month: int,
current_user: Annotated[User, Depends(get_current_user)],
):
logger.info(f"User '{current_user.full_name}' accessed data for {year}-{month}.")
data = OdooClient.get_hr_attendance_data(month=month, year=year)
if not data:
raise HTTPException(
status_code=404,
detail="No attendance data found for the specified month and year."
)
return create_response(year, month, data)

View File

128
backend/odoo_api/client.py Normal file
View File

@@ -0,0 +1,128 @@
import requests
import json
import logging
import calendar
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Depends, HTTPException, status
logger = logging.getLogger("uvicorn")
class OdooAPIClient:
def __init__(self, odoo_url: str, db_name: str, secret_key: str, algorithm: str):
self.odoo_url = odoo_url
self.db_name = db_name
self.session = requests.Session()
def login(self, username: str, password: str) -> dict:
auth_url = f"{self.odoo_url}/web/session/authenticate"
payload = {
"jsonrpc": "2.0",
"method": "call",
"params": {
"db": self.db_name,
"login": username,
"password": password
},
"id": 1
}
headers = {'Content-Type': 'application/json'}
response = self.session.post(auth_url, json=payload, headers=headers)
if response.status_code != 200:
# logger.error(
# f"Authentication failed with status code {response.status_code}")
raise Exception(
f"Authentication failed with status code {response.status_code}")
# for cookie in response.cookies:
# self._session_cookies[cookie.name] = cookie.value
data = response.json()
if "error" in data:
raise HTTPException(
status_code=502,
detail=f"Odoo RPC error: {data['error'].get('message', 'Unknown error')}"
)
return data["result"]
def get_hr_attendance_data(self, month=None, year=None, limit=None, domain=None):
if month is None:
month = datetime.now(timezone.utc).month
if year is None:
year = datetime.now(timezone.utc).year
if domain is None:
domain = [] # Default to no domain filter
url = f"{self.odoo_url}/hr.attendance"
first_day_of_month = datetime(year, month, 1)
last_day_of_month = datetime(
year, month, calendar.monthrange(year, month)[1], 23, 59, 59)
start_date_str = first_day_of_month.strftime('%Y-%m-%d %H:%M:%S')
end_date_str = last_day_of_month.strftime('%Y-%m-%d %H:%M:%S')
domain.extend([
('check_in', '>=', start_date_str),
('check_in', '<=', end_date_str)
])
kwargs = {
"domain": domain,
"fields": ["id", "employee_id", "check_in", "check_out", "worked_hours", "attendance_reason_ids"],
}
if limit is not None:
kwargs["limit"] = limit
result = self.call_odoo_method(
"hr.attendance", "web_search_read", kwargs=kwargs)
if "records" not in result:
# logger.error("Unexpected response format: 'records' not found")
raise HTTPException(
status_code=502,
detail="Unexpected response format: 'records' not found"
)
sorted_data = sorted(result["records"], key=lambda x: x["check_in"])
return sorted_data
def call_odoo_method(self, model: str, method: str, args: list = None, kwargs: dict = None):
if args is None:
args = []
if kwargs is None:
kwargs = {}
url = f"{self.odoo_url}/web/dataset/call_kw/{model}/{method}"
headers = {'Content-Type': 'application/json'}
payload = {
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": model,
"method": method,
"args": args,
"kwargs": kwargs
},
"id": 1
}
response = self.session.post(url, json=payload, headers=headers)
# response = requests.post(url, json=payload, headers=headers, cookies=self._session_cookies)
if response.status_code != 200:
# logger.error(
# f"Failed to call Odoo method {method} on model {model}: {response.status_code}")
raise HTTPException(
status_code=response.status_code,
detail=f"Failed to call Odoo method {method} on model {model}"
)
data = response.json()
if "error" in data:
raise HTTPException(
status_code=502,
detail=f"Odoo RPC error: {data['error'].get('message', 'Unknown error')}"
)
return data["result"]

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)),
},
},
})