ready to deployment
This commit is contained in:
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.env
|
||||
test.http
|
||||
175
backend/main.py
Normal file
175
backend/main.py
Normal 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)
|
||||
|
||||
|
||||
0
backend/odoo_api/__init__.py
Normal file
0
backend/odoo_api/__init__.py
Normal file
128
backend/odoo_api/client.py
Normal file
128
backend/odoo_api/client.py
Normal 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
8
frontend/.editorconfig
Normal 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
1
frontend/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
31
frontend/.gitignore
vendored
Normal file
31
frontend/.gitignore
vendored
Normal 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
|
||||
6
frontend/.prettierrc.json
Normal file
6
frontend/.prettierrc.json
Normal 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
8
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
35
frontend/README.md
Normal file
35
frontend/README.md
Normal 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
26
frontend/eslint.config.js
Normal 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
23
frontend/index.html
Normal 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
8
frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4763
frontend/package-lock.json
generated
Normal file
4763
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal 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
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
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
38
frontend/src/api/api.js
Normal file
38
frontend/src/api/api.js
Normal 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
|
||||
11
frontend/src/api/authApi.js
Normal file
11
frontend/src/api/authApi.js
Normal 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" }
|
||||
}
|
||||
7
frontend/src/api/dataApi.js
Normal file
7
frontend/src/api/dataApi.js
Normal 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
|
||||
}
|
||||
19
frontend/src/api/mock_response.js
Normal file
19
frontend/src/api/mock_response.js
Normal 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
|
||||
}
|
||||
},
|
||||
}
|
||||
298
frontend/src/assets/main.css
Normal file
298
frontend/src/assets/main.css
Normal 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);
|
||||
}
|
||||
134
frontend/src/components/BarChart.vue
Normal file
134
frontend/src/components/BarChart.vue
Normal 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' // pn–pt
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: '#e0e0e0',
|
||||
},
|
||||
ticks: {
|
||||
color: '#333',
|
||||
stepSize: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
27
frontend/src/components/CardTop.vue
Normal file
27
frontend/src/components/CardTop.vue
Normal 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>
|
||||
222
frontend/src/components/DayRow.vue
Normal file
222
frontend/src/components/DayRow.vue
Normal 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>
|
||||
141
frontend/src/components/HoursSummary.vue
Normal file
141
frontend/src/components/HoursSummary.vue
Normal 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>
|
||||
151
frontend/src/components/MonthMenu.vue
Normal file
151
frontend/src/components/MonthMenu.vue
Normal 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>
|
||||
37
frontend/src/components/TimeLabel.vue
Normal file
37
frontend/src/components/TimeLabel.vue
Normal 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>
|
||||
290
frontend/src/components/TimeStepper.vue
Normal file
290
frontend/src/components/TimeStepper.vue
Normal 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
14
frontend/src/main.js
Normal 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')
|
||||
34
frontend/src/router/index.js
Normal file
34
frontend/src/router/index.js
Normal 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
|
||||
113
frontend/src/stores/attendanceStore.js
Normal file
113
frontend/src/stores/attendanceStore.js
Normal 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,
|
||||
}
|
||||
})
|
||||
23
frontend/src/stores/authStore.js
Normal file
23
frontend/src/stores/authStore.js
Normal 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
234
frontend/src/utils/utils.js
Normal 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 (1–12), 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,
|
||||
}
|
||||
95
frontend/src/views/DashboardView.vue
Normal file
95
frontend/src/views/DashboardView.vue
Normal 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>
|
||||
220
frontend/src/views/LoginView.vue
Normal file
220
frontend/src/views/LoginView.vue
Normal 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
15
frontend/vite.config.js
Normal 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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user