@@ -0,0 +1,63 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import secrets
|
||||
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import LoginAttempt, User
|
||||
|
||||
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
def new_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def register_failed_attempt(db: Session, email: str, ip_address: str) -> None:
|
||||
db.add(LoginAttempt(email=email.lower(), ip_address=ip_address, success=False))
|
||||
db.commit()
|
||||
|
||||
|
||||
def register_successful_attempt(db: Session, email: str, ip_address: str) -> None:
|
||||
db.add(LoginAttempt(email=email.lower(), ip_address=ip_address, success=True))
|
||||
db.commit()
|
||||
|
||||
|
||||
def is_login_blocked(
|
||||
db: Session,
|
||||
email: str,
|
||||
ip_address: str,
|
||||
max_attempts: int,
|
||||
window_minutes: int,
|
||||
) -> tuple[bool, int]:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window_minutes)
|
||||
|
||||
stmt = (
|
||||
select(func.count(LoginAttempt.id))
|
||||
.where(
|
||||
LoginAttempt.success.is_(False),
|
||||
LoginAttempt.created_at >= cutoff,
|
||||
(LoginAttempt.email == email.lower()) | (LoginAttempt.ip_address == ip_address),
|
||||
)
|
||||
)
|
||||
count = db.execute(stmt).scalar_one()
|
||||
|
||||
if count >= max_attempts:
|
||||
return True, window_minutes
|
||||
|
||||
return False, 0
|
||||
|
||||
|
||||
def find_user_by_email(db: Session, email: str) -> User | None:
|
||||
stmt = select(User).where(User.email == email.lower())
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
@@ -0,0 +1,59 @@
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
VERSION_FILE = ROOT_DIR / "VERSION"
|
||||
|
||||
|
||||
def load_default_app_version() -> str:
|
||||
try:
|
||||
value = VERSION_FILE.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return "1.0.0"
|
||||
return value or "1.0.0"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_env: str = Field(default="development", alias="APP_ENV")
|
||||
port: int = Field(default=8000, alias="PORT")
|
||||
db_url: str = Field(default="sqlite:///./data/stundentracker.db", alias="DB_URL")
|
||||
session_secret: str = Field(default="change-this-in-production", alias="SESSION_SECRET")
|
||||
cookie_secure: bool = Field(default=False, alias="COOKIE_SECURE")
|
||||
cookie_samesite: str = Field(default="lax", alias="COOKIE_SAMESITE")
|
||||
login_rate_limit_attempts: int = Field(default=5, alias="LOGIN_RATE_LIMIT_ATTEMPTS")
|
||||
login_rate_limit_window_minutes: int = Field(default=15, alias="LOGIN_RATE_LIMIT_WINDOW_MINUTES")
|
||||
data_encryption_key: str | None = Field(default=None, alias="DATA_ENCRYPTION_KEY")
|
||||
password_reset_token_ttl_minutes: int = Field(default=60, alias="PASSWORD_RESET_TOKEN_TTL_MINUTES")
|
||||
mfa_code_ttl_minutes: int = Field(default=10, alias="MFA_CODE_TTL_MINUTES")
|
||||
mfa_pending_ttl_minutes: int = Field(default=10, alias="MFA_PENDING_TTL_MINUTES")
|
||||
smtp_timeout_seconds: int = Field(default=15, alias="SMTP_TIMEOUT_SECONDS")
|
||||
registration_notify_email: str = Field(default="admin@example.com", alias="REGISTRATION_NOTIFY_EMAIL")
|
||||
app_name: str = Field(default="Stundenfuchs", alias="APP_NAME")
|
||||
app_title: str | None = Field(default=None, alias="APP_TITLE")
|
||||
app_version: str = Field(default=load_default_app_version(), alias="APP_VERSION")
|
||||
email_verification_required: bool = Field(default=True, alias="EMAIL_VERIFICATION_REQUIRED")
|
||||
email_verification_token_ttl_minutes: int = Field(default=60 * 24, alias="EMAIL_VERIFICATION_TOKEN_TTL_MINUTES")
|
||||
bootstrap_admin_email: str | None = Field(default=None, alias="BOOTSTRAP_ADMIN_EMAIL")
|
||||
forwarded_allow_ips: str = Field(default="127.0.0.1,::1", alias="FORWARDED_ALLOW_IPS")
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
@property
|
||||
def resolved_app_title(self) -> str:
|
||||
value = (self.app_title or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return self.app_name
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,39 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
_ENGINE: Engine | None = None
|
||||
_SessionLocal: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def init_engine(db_url: str) -> Engine:
|
||||
global _ENGINE, _SessionLocal
|
||||
|
||||
connect_args = {}
|
||||
if db_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
_ENGINE = create_engine(db_url, future=True, connect_args=connect_args)
|
||||
_SessionLocal = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False, future=True)
|
||||
return _ENGINE
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
if _ENGINE is None:
|
||||
raise RuntimeError("Database engine is not initialized")
|
||||
return _ENGINE
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
if _SessionLocal is None:
|
||||
raise RuntimeError("SessionLocal is not initialized")
|
||||
db = _SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import Base, get_engine, init_engine
|
||||
from app.config import get_settings
|
||||
from app.services.migrations import run_startup_migrations
|
||||
from app.services.targets import ensure_all_users_have_default_target_rules
|
||||
from app.services.auto_entries import sync_auto_entries_for_all_users
|
||||
|
||||
|
||||
def main() -> None:
|
||||
settings = get_settings()
|
||||
init_engine(settings.db_url)
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_startup_migrations(engine)
|
||||
with Session(engine) as db:
|
||||
ensure_all_users_have_default_target_rules(db)
|
||||
result = sync_auto_entries_for_all_users(db=db)
|
||||
db.commit()
|
||||
print(
|
||||
"auto_entry_sync users={users} created={created} deleted_future={deleted_future}".format(
|
||||
**result
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,311 @@
|
||||
from datetime import date, datetime, timezone
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
weekly_target_minutes: Mapped[int] = mapped_column(Integer, default=1500, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(32), default="user", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
preferred_home_view: Mapped[str] = mapped_column(String(16), default="week", nullable=False)
|
||||
preferred_month_view_mode: Mapped[str] = mapped_column(String(16), default="flat", nullable=False)
|
||||
entry_mode: Mapped[str] = mapped_column(String(16), default="manual", nullable=False)
|
||||
working_days_csv: Mapped[str] = mapped_column(String(32), default="0,1,2,3,4", nullable=False)
|
||||
count_vacation_as_worktime: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
count_holiday_as_worktime: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
count_sick_as_worktime: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
automatic_break_rules_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
default_break_minutes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
overtime_start_date: Mapped[date | None] = mapped_column(Date, default=None)
|
||||
overtime_expiry_days: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
expire_negative_overtime: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
vacation_days_total: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
vacation_show_in_header: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
workhours_counter_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
workhours_counter_show_in_header: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
workhours_counter_start_date: Mapped[date | None] = mapped_column(Date, default=None)
|
||||
workhours_counter_end_date: Mapped[date | None] = mapped_column(Date, default=None)
|
||||
workhours_counter_manual_offset_minutes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
workhours_counter_target_minutes: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
workhours_counter_target_email_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
workhours_counter_warning_last_sent_on: Mapped[date | None] = mapped_column(Date, default=None)
|
||||
workhours_counter_warning_last_sent_key: Mapped[str | None] = mapped_column(String(120), default=None)
|
||||
federal_state: Mapped[str | None] = mapped_column(String(8), default=None)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
email_verification_token_hash: Mapped[str | None] = mapped_column(String(128), default=None, index=True)
|
||||
email_verification_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||
email_verification_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||
mfa_method: Mapped[str] = mapped_column(String(16), default="none", nullable=False)
|
||||
mfa_totp_secret_encrypted: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
mfa_email_code_hash: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
mfa_email_code_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||
mfa_email_code_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||
|
||||
time_entries: Mapped[list["TimeEntry"]] = relationship(
|
||||
"TimeEntry", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
weekly_target_rules: Mapped[list["WeeklyTargetRule"]] = relationship(
|
||||
"WeeklyTargetRule",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="WeeklyTargetRule.effective_from",
|
||||
)
|
||||
vacation_periods: Mapped[list["VacationPeriod"]] = relationship(
|
||||
"VacationPeriod",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="VacationPeriod.start_date",
|
||||
)
|
||||
special_day_statuses: Mapped[list["SpecialDayStatus"]] = relationship(
|
||||
"SpecialDayStatus",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="SpecialDayStatus.date",
|
||||
)
|
||||
overtime_adjustments: Mapped[list["OvertimeAdjustment"]] = relationship(
|
||||
"OvertimeAdjustment",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="OvertimeAdjustment.date",
|
||||
)
|
||||
auto_entry_skips: Mapped[list["AutoEntrySkip"]] = relationship(
|
||||
"AutoEntrySkip",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AutoEntrySkip.date",
|
||||
)
|
||||
password_reset_tokens: Mapped[list["PasswordResetToken"]] = relationship(
|
||||
"PasswordResetToken",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PasswordResetToken.created_at.desc()",
|
||||
)
|
||||
import_previews: Mapped[list["ImportPreview"]] = relationship(
|
||||
"ImportPreview",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ImportPreview.created_at.desc()",
|
||||
)
|
||||
support_tickets: Mapped[list["SupportTicket"]] = relationship(
|
||||
"SupportTicket",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="SupportTicket.created_at.desc()",
|
||||
foreign_keys="SupportTicket.user_id",
|
||||
)
|
||||
|
||||
|
||||
class TimeEntry(Base):
|
||||
__tablename__ = "time_entries"
|
||||
__table_args__ = (UniqueConstraint("user_id", "date", name="uq_user_date"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
date: Mapped[date] = mapped_column(Date, index=True, nullable=False)
|
||||
|
||||
start_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
end_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
break_minutes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
break_rule_mode: Mapped[str] = mapped_column(String(16), default="manual", nullable=False)
|
||||
|
||||
notes: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="time_entries")
|
||||
|
||||
|
||||
class LoginAttempt(Base):
|
||||
__tablename__ = "login_attempts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
ip_address: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
|
||||
)
|
||||
|
||||
|
||||
class WeeklyTargetRule(Base):
|
||||
__tablename__ = "weekly_target_rules"
|
||||
__table_args__ = (UniqueConstraint("user_id", "effective_from", name="uq_user_effective_from"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
effective_from: Mapped[date] = mapped_column(Date, index=True, nullable=False)
|
||||
weekly_target_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="weekly_target_rules")
|
||||
|
||||
|
||||
class VacationPeriod(Base):
|
||||
__tablename__ = "vacation_periods"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
start_date: Mapped[date] = mapped_column(Date, index=True, nullable=False)
|
||||
end_date: Mapped[date] = mapped_column(Date, index=True, nullable=False)
|
||||
include_weekends: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="vacation_periods")
|
||||
|
||||
|
||||
class SpecialDayStatus(Base):
|
||||
__tablename__ = "special_day_statuses"
|
||||
__table_args__ = (UniqueConstraint("user_id", "date", name="uq_user_special_day_date"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
date: Mapped[date] = mapped_column(Date, index=True, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False) # holiday | sick
|
||||
notes: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="special_day_statuses")
|
||||
|
||||
|
||||
class OvertimeAdjustment(Base):
|
||||
__tablename__ = "overtime_adjustments"
|
||||
__table_args__ = (UniqueConstraint("user_id", "date", name="uq_user_overtime_adjustment_date"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
date: Mapped[date] = mapped_column(Date, index=True, nullable=False)
|
||||
minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="overtime_adjustments")
|
||||
|
||||
|
||||
class AutoEntrySkip(Base):
|
||||
__tablename__ = "auto_entry_skips"
|
||||
__table_args__ = (UniqueConstraint("user_id", "date", name="uq_user_auto_entry_skip_date"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
date: Mapped[date] = mapped_column(Date, index=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="auto_entry_skips")
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||
requested_ip: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="password_reset_tokens")
|
||||
|
||||
|
||||
class ImportPreview(Base):
|
||||
__tablename__ = "import_previews"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
mode: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="import_previews")
|
||||
|
||||
|
||||
class EmailServerConfig(Base):
|
||||
__tablename__ = "email_server_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
smtp_host: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
smtp_port: Mapped[int] = mapped_column(Integer, default=587, nullable=False)
|
||||
smtp_username: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
from_email: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
from_name: Mapped[str] = mapped_column(String(255), default="Stundenfuchs", nullable=False)
|
||||
use_starttls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
use_ssl: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
verify_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
registration_mails_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
password_reset_mails_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
registration_admin_notify_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
registration_admin_notify_admin_ids_csv: Mapped[str | None] = mapped_column(String(1024), default=None)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class SiteContent(Base):
|
||||
__tablename__ = "site_content"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
markdown_text: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
category: Mapped[str] = mapped_column(String(24), default="problem", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(24), default="open", nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
admin_notes: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
source_ip_hash: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
source_user_agent: Mapped[str | None] = mapped_column(String(512), default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||
|
||||
user: Mapped[User | None] = relationship("User", back_populates="support_tickets", foreign_keys=[user_id])
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=10, max_length=255)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class MFAChallengeRequest(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class TimeEntryCreate(BaseModel):
|
||||
date: date
|
||||
start_time: str
|
||||
end_time: str
|
||||
break_minutes: int | None = Field(default=None, ge=0)
|
||||
break_mode: str | None = Field(default=None, pattern="^(manual|auto)$")
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class TimeEntryUpdate(BaseModel):
|
||||
start_time: str | None = None
|
||||
end_time: str | None = None
|
||||
break_minutes: int | None = Field(default=None, ge=0)
|
||||
break_mode: str | None = Field(default=None, pattern="^(manual|auto)$")
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class TimeEntryOut(BaseModel):
|
||||
id: str
|
||||
date: date
|
||||
start_time: str
|
||||
end_time: str
|
||||
break_minutes: int
|
||||
break_mode: str
|
||||
net_minutes: int
|
||||
notes: str | None
|
||||
@@ -0,0 +1,332 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AutoEntrySkip, OvertimeAdjustment, SpecialDayStatus, TimeEntry, User
|
||||
from app.services.calculations import automatic_break_minutes_for_net_minutes, compute_net_minutes
|
||||
from app.services.targets import list_rules_for_user, monday_of, target_for_week
|
||||
from app.services.vacations import expand_vacation_dates, list_vacations_for_user
|
||||
from app.services.workdays import parse_working_days_csv
|
||||
|
||||
ENTRY_MODE_MANUAL = "manual"
|
||||
ENTRY_MODE_AUTO_UNTIL_TODAY = "auto_until_today"
|
||||
AUTO_ENTRY_NOTE = "Automatisch vorausgefuellt"
|
||||
SPECIAL_DAY_STATUS_HOLIDAY = "holiday"
|
||||
SPECIAL_DAY_STATUS_SICK = "sick"
|
||||
|
||||
|
||||
def get_user_working_days(user: User) -> set[int]:
|
||||
return parse_working_days_csv(user.working_days_csv)
|
||||
|
||||
|
||||
def list_special_statuses_for_user(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
) -> list[SpecialDayStatus]:
|
||||
stmt = (
|
||||
select(SpecialDayStatus)
|
||||
.where(
|
||||
SpecialDayStatus.user_id == user_id,
|
||||
SpecialDayStatus.date >= from_date,
|
||||
SpecialDayStatus.date <= to_date,
|
||||
)
|
||||
.order_by(SpecialDayStatus.date.asc())
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def special_status_map(periods: list[SpecialDayStatus]) -> dict[date, str]:
|
||||
return {period.date: period.status for period in periods}
|
||||
|
||||
|
||||
def special_status_dates(periods: list[SpecialDayStatus]) -> set[date]:
|
||||
return {period.date for period in periods}
|
||||
|
||||
|
||||
def count_as_worktime_dates_for_user(
|
||||
*,
|
||||
user: User,
|
||||
vacation_dates: set[date],
|
||||
special_statuses: list[SpecialDayStatus],
|
||||
) -> set[date]:
|
||||
dates: set[date] = set()
|
||||
if user.count_vacation_as_worktime:
|
||||
dates.update(vacation_dates)
|
||||
if user.count_holiday_as_worktime:
|
||||
dates.update(period.date for period in special_statuses if period.status == SPECIAL_DAY_STATUS_HOLIDAY)
|
||||
if user.count_sick_as_worktime:
|
||||
dates.update(period.date for period in special_statuses if period.status == SPECIAL_DAY_STATUS_SICK)
|
||||
return dates
|
||||
|
||||
|
||||
def effective_non_working_dates_for_user(
|
||||
*,
|
||||
user: User,
|
||||
special_statuses: list[SpecialDayStatus],
|
||||
) -> set[date]:
|
||||
blocked: set[date] = set()
|
||||
for period in special_statuses:
|
||||
if period.status == SPECIAL_DAY_STATUS_HOLIDAY and user.count_holiday_as_worktime:
|
||||
continue
|
||||
if period.status == SPECIAL_DAY_STATUS_SICK and user.count_sick_as_worktime:
|
||||
continue
|
||||
blocked.add(period.date)
|
||||
return blocked
|
||||
|
||||
|
||||
def clear_special_status_for_date(*, db: Session, user_id: str, day: date) -> None:
|
||||
stmt = select(SpecialDayStatus).where(SpecialDayStatus.user_id == user_id, SpecialDayStatus.date == day)
|
||||
existing = db.execute(stmt).scalar_one_or_none()
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
|
||||
|
||||
def list_overtime_adjustments_for_user(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
) -> list[OvertimeAdjustment]:
|
||||
stmt = (
|
||||
select(OvertimeAdjustment)
|
||||
.where(
|
||||
OvertimeAdjustment.user_id == user_id,
|
||||
OvertimeAdjustment.date >= from_date,
|
||||
OvertimeAdjustment.date <= to_date,
|
||||
)
|
||||
.order_by(OvertimeAdjustment.date.asc())
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def overtime_adjustment_map(adjustments: list[OvertimeAdjustment]) -> dict[date, OvertimeAdjustment]:
|
||||
return {adjustment.date: adjustment for adjustment in adjustments}
|
||||
|
||||
|
||||
def overtime_adjustment_minutes_map(adjustments: list[OvertimeAdjustment]) -> dict[date, int]:
|
||||
return {adjustment.date: adjustment.minutes for adjustment in adjustments}
|
||||
|
||||
|
||||
def clear_overtime_adjustment_for_date(*, db: Session, user_id: str, day: date) -> None:
|
||||
stmt = select(OvertimeAdjustment).where(OvertimeAdjustment.user_id == user_id, OvertimeAdjustment.date == day)
|
||||
existing = db.execute(stmt).scalar_one_or_none()
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
|
||||
|
||||
def auto_entry_skip_dates_for_user(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
) -> set[date]:
|
||||
stmt = (
|
||||
select(AutoEntrySkip.date)
|
||||
.where(
|
||||
AutoEntrySkip.user_id == user_id,
|
||||
AutoEntrySkip.date >= from_date,
|
||||
AutoEntrySkip.date <= to_date,
|
||||
)
|
||||
.order_by(AutoEntrySkip.date.asc())
|
||||
)
|
||||
return set(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def mark_auto_entry_skip_for_date(*, db: Session, user_id: str, day: date) -> None:
|
||||
stmt = select(AutoEntrySkip).where(AutoEntrySkip.user_id == user_id, AutoEntrySkip.date == day)
|
||||
existing = db.execute(stmt).scalar_one_or_none()
|
||||
if not existing:
|
||||
db.add(AutoEntrySkip(user_id=user_id, date=day))
|
||||
|
||||
|
||||
def clear_auto_entry_skip_for_date(*, db: Session, user_id: str, day: date) -> None:
|
||||
stmt = select(AutoEntrySkip).where(AutoEntrySkip.user_id == user_id, AutoEntrySkip.date == day)
|
||||
existing = db.execute(stmt).scalar_one_or_none()
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
|
||||
|
||||
def build_auto_day_entry(
|
||||
*,
|
||||
weekly_target_minutes: int,
|
||||
workdays_per_week: int,
|
||||
automatic_break_rules_enabled: bool,
|
||||
default_break_minutes: int,
|
||||
) -> tuple[int, int, int] | None:
|
||||
if workdays_per_week <= 0:
|
||||
return None
|
||||
|
||||
day_net_minutes = int(round(weekly_target_minutes / workdays_per_week))
|
||||
if day_net_minutes <= 0:
|
||||
return None
|
||||
|
||||
start_minutes = 8 * 60 + 30
|
||||
break_minutes = (
|
||||
automatic_break_minutes_for_net_minutes(day_net_minutes)
|
||||
if automatic_break_rules_enabled
|
||||
else max(0, default_break_minutes)
|
||||
)
|
||||
end_minutes = start_minutes + day_net_minutes + break_minutes
|
||||
|
||||
if end_minutes > (24 * 60 - 1):
|
||||
end_minutes = 24 * 60 - 1
|
||||
available_span = end_minutes - start_minutes
|
||||
if available_span <= 0:
|
||||
return None
|
||||
break_minutes = min(break_minutes, max(0, available_span - 1))
|
||||
|
||||
return start_minutes, end_minutes, break_minutes
|
||||
|
||||
|
||||
def auto_entry_sync_start_date(user: User) -> date:
|
||||
if user.overtime_start_date:
|
||||
return user.overtime_start_date
|
||||
return user.created_at.date()
|
||||
|
||||
|
||||
def delete_future_auto_entries(
|
||||
*,
|
||||
db: Session,
|
||||
user_id: str,
|
||||
after_date: date,
|
||||
) -> int:
|
||||
stmt = (
|
||||
select(TimeEntry)
|
||||
.where(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.date > after_date,
|
||||
TimeEntry.notes == AUTO_ENTRY_NOTE,
|
||||
)
|
||||
.order_by(TimeEntry.date.asc())
|
||||
)
|
||||
entries = db.execute(stmt).scalars().all()
|
||||
for entry in entries:
|
||||
db.delete(entry)
|
||||
return len(entries)
|
||||
|
||||
|
||||
def autofill_entries_for_range(
|
||||
*,
|
||||
db: Session,
|
||||
user: User,
|
||||
range_start: date,
|
||||
range_end: date,
|
||||
) -> int:
|
||||
if user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY:
|
||||
return 0
|
||||
if range_end < range_start:
|
||||
return 0
|
||||
|
||||
effective_end = min(range_end, date.today())
|
||||
effective_start = max(range_start, auto_entry_sync_start_date(user))
|
||||
if effective_start > effective_end:
|
||||
return 0
|
||||
|
||||
working_days = get_user_working_days(user)
|
||||
if not working_days:
|
||||
return 0
|
||||
workdays_per_week = len(working_days)
|
||||
|
||||
rules = list_rules_for_user(db, user.id)
|
||||
vacations = list_vacations_for_user(db, user.id, effective_start, effective_end)
|
||||
vacation_dates = expand_vacation_dates(vacations, effective_start, effective_end, relevant_weekdays=working_days)
|
||||
special_statuses = list_special_statuses_for_user(db, user.id, effective_start, effective_end)
|
||||
special_dates = special_status_dates(special_statuses)
|
||||
overtime_adjustments = list_overtime_adjustments_for_user(db, user.id, effective_start, effective_end)
|
||||
adjustment_dates = set(overtime_adjustment_minutes_map(overtime_adjustments).keys())
|
||||
skipped_auto_dates = auto_entry_skip_dates_for_user(db, user.id, effective_start, effective_end)
|
||||
|
||||
existing_dates_stmt = (
|
||||
select(TimeEntry.date)
|
||||
.where(
|
||||
TimeEntry.user_id == user.id,
|
||||
TimeEntry.date >= effective_start,
|
||||
TimeEntry.date <= effective_end,
|
||||
)
|
||||
.order_by(TimeEntry.date.asc())
|
||||
)
|
||||
existing_dates = set(db.execute(existing_dates_stmt).scalars().all())
|
||||
|
||||
created = 0
|
||||
cursor = effective_start
|
||||
while cursor <= effective_end:
|
||||
if cursor in existing_dates:
|
||||
cursor += timedelta(days=1)
|
||||
continue
|
||||
if cursor in vacation_dates:
|
||||
cursor += timedelta(days=1)
|
||||
continue
|
||||
if cursor in special_dates:
|
||||
cursor += timedelta(days=1)
|
||||
continue
|
||||
if cursor in adjustment_dates:
|
||||
cursor += timedelta(days=1)
|
||||
continue
|
||||
if cursor in skipped_auto_dates:
|
||||
cursor += timedelta(days=1)
|
||||
continue
|
||||
if cursor.weekday() not in working_days:
|
||||
cursor += timedelta(days=1)
|
||||
continue
|
||||
|
||||
weekly_target_minutes = target_for_week(rules, monday_of(cursor), user.weekly_target_minutes)
|
||||
entry_values = build_auto_day_entry(
|
||||
weekly_target_minutes=weekly_target_minutes,
|
||||
workdays_per_week=workdays_per_week,
|
||||
automatic_break_rules_enabled=bool(user.automatic_break_rules_enabled),
|
||||
default_break_minutes=user.default_break_minutes,
|
||||
)
|
||||
if entry_values is None:
|
||||
cursor += timedelta(days=1)
|
||||
continue
|
||||
start_minutes, end_minutes, break_minutes = entry_values
|
||||
|
||||
db.add(
|
||||
TimeEntry(
|
||||
user_id=user.id,
|
||||
date=cursor,
|
||||
start_minutes=start_minutes,
|
||||
end_minutes=end_minutes,
|
||||
break_minutes=break_minutes,
|
||||
break_rule_mode="auto",
|
||||
notes=AUTO_ENTRY_NOTE,
|
||||
)
|
||||
)
|
||||
existing_dates.add(cursor)
|
||||
created += 1
|
||||
cursor += timedelta(days=1)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def sync_auto_entries_for_all_users(
|
||||
*,
|
||||
db: Session,
|
||||
up_to_date: date | None = None,
|
||||
) -> dict[str, int]:
|
||||
effective_date = up_to_date or date.today()
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(
|
||||
User.is_active.is_(True),
|
||||
User.entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY,
|
||||
)
|
||||
.order_by(User.created_at.asc())
|
||||
)
|
||||
users = db.execute(stmt).scalars().all()
|
||||
|
||||
created = 0
|
||||
deleted = 0
|
||||
for user in users:
|
||||
deleted += delete_future_auto_entries(db=db, user_id=user.id, after_date=effective_date)
|
||||
created += autofill_entries_for_range(
|
||||
db=db,
|
||||
user=user,
|
||||
range_start=auto_entry_sync_start_date(user),
|
||||
range_end=effective_date,
|
||||
)
|
||||
|
||||
return {"users": len(users), "created": created, "deleted_future": deleted}
|
||||
@@ -0,0 +1,126 @@
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
import re
|
||||
|
||||
|
||||
def parse_time_to_minutes(value: str) -> int:
|
||||
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", value):
|
||||
raise ValueError("Uhrzeit muss im Format HH:MM sein")
|
||||
try:
|
||||
parsed = datetime.strptime(value, "%H:%M")
|
||||
except ValueError as exc:
|
||||
raise ValueError("Uhrzeit muss im Format HH:MM sein") from exc
|
||||
return parsed.hour * 60 + parsed.minute
|
||||
|
||||
|
||||
def minutes_to_hhmm(minutes: int) -> str:
|
||||
sign = "-" if minutes < 0 else ""
|
||||
minutes_abs = abs(minutes)
|
||||
hours = minutes_abs // 60
|
||||
mins = minutes_abs % 60
|
||||
return f"{sign}{hours:02d}:{mins:02d}"
|
||||
|
||||
|
||||
def validate_entry(start_minutes: int, end_minutes: int, break_minutes: int) -> None:
|
||||
if end_minutes <= start_minutes:
|
||||
raise ValueError("Arbeitsende muss nach Arbeitsbeginn liegen")
|
||||
|
||||
if break_minutes < 0:
|
||||
raise ValueError("Pause darf nicht negativ sein")
|
||||
|
||||
gross_minutes = end_minutes - start_minutes
|
||||
if break_minutes > gross_minutes:
|
||||
raise ValueError("Pause darf nicht laenger als die Arbeitszeit sein")
|
||||
|
||||
|
||||
def required_break_minutes_for_span(work_span_minutes: int) -> int:
|
||||
if work_span_minutes > 9 * 60:
|
||||
return 45
|
||||
if work_span_minutes > 6 * 60:
|
||||
return 30
|
||||
return 0
|
||||
|
||||
|
||||
def automatic_break_minutes(start_minutes: int, end_minutes: int) -> int:
|
||||
if end_minutes <= start_minutes:
|
||||
raise ValueError("Arbeitsende muss nach Arbeitsbeginn liegen")
|
||||
return required_break_minutes_for_span(end_minutes - start_minutes)
|
||||
|
||||
|
||||
def automatic_break_minutes_for_net_minutes(net_minutes: int) -> int:
|
||||
if net_minutes < 0:
|
||||
raise ValueError("Nettoarbeitszeit darf nicht negativ sein")
|
||||
if net_minutes > (9 * 60 - 45):
|
||||
return 45
|
||||
if net_minutes > (6 * 60 - 30):
|
||||
return 30
|
||||
return 0
|
||||
|
||||
|
||||
def compute_net_minutes(start_minutes: int, end_minutes: int, break_minutes: int) -> int:
|
||||
validate_entry(start_minutes, end_minutes, break_minutes)
|
||||
return (end_minutes - start_minutes) - break_minutes
|
||||
|
||||
|
||||
def iso_week_bounds(day: date) -> tuple[date, date]:
|
||||
week_start = day - timedelta(days=day.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
return week_start, week_end
|
||||
|
||||
|
||||
def daterange(start: date, end: date):
|
||||
current = start
|
||||
while current <= end:
|
||||
yield current
|
||||
current += timedelta(days=1)
|
||||
|
||||
|
||||
def aggregate_week(entries: list, week_start: date, weekly_target_minutes: int) -> dict:
|
||||
week_end = week_start + timedelta(days=6)
|
||||
entries_by_date = {entry.date: entry for entry in entries}
|
||||
|
||||
days = []
|
||||
weekly_ist = 0
|
||||
for day in daterange(week_start, week_end):
|
||||
entry = entries_by_date.get(day)
|
||||
if entry is None:
|
||||
days.append({"date": day, "entry": None, "net_minutes": 0})
|
||||
continue
|
||||
|
||||
net_minutes = compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes)
|
||||
weekly_ist += net_minutes
|
||||
days.append({"date": day, "entry": entry, "net_minutes": net_minutes})
|
||||
|
||||
weekly_delta = weekly_ist - weekly_target_minutes
|
||||
|
||||
return {
|
||||
"week_start": week_start,
|
||||
"week_end": week_end,
|
||||
"days": days,
|
||||
"weekly_ist": weekly_ist,
|
||||
"weekly_soll": weekly_target_minutes,
|
||||
"weekly_delta": weekly_delta,
|
||||
}
|
||||
|
||||
|
||||
def cumulative_delta(entries: list, selected_week_start: date, weekly_target_minutes: int) -> int:
|
||||
if not entries:
|
||||
return 0
|
||||
|
||||
earliest_entry_date = min(entry.date for entry in entries)
|
||||
current_week_start = earliest_entry_date - timedelta(days=earliest_entry_date.weekday())
|
||||
|
||||
net_by_week_start = defaultdict(int)
|
||||
for entry in entries:
|
||||
week_start, _ = iso_week_bounds(entry.date)
|
||||
net_by_week_start[week_start] += compute_net_minutes(
|
||||
entry.start_minutes, entry.end_minutes, entry.break_minutes
|
||||
)
|
||||
|
||||
running = 0
|
||||
while current_week_start <= selected_week_start:
|
||||
weekly_ist = net_by_week_start[current_week_start]
|
||||
running += weekly_ist - weekly_target_minutes
|
||||
current_week_start += timedelta(days=7)
|
||||
|
||||
return running
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
import smtplib
|
||||
import ssl
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailServerSettings:
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_username: str | None
|
||||
smtp_password: str | None
|
||||
from_email: str
|
||||
from_name: str
|
||||
use_starttls: bool
|
||||
use_ssl: bool
|
||||
verify_tls: bool
|
||||
timeout_seconds: int = 15
|
||||
|
||||
|
||||
def _build_context(verify_tls: bool) -> ssl.SSLContext:
|
||||
context = ssl.create_default_context()
|
||||
if verify_tls:
|
||||
return context
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return context
|
||||
|
||||
|
||||
def send_email(
|
||||
*,
|
||||
settings: MailServerSettings,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
text_body: str,
|
||||
) -> None:
|
||||
if not settings.smtp_host.strip():
|
||||
raise ValueError("SMTP host is empty")
|
||||
if not settings.from_email.strip():
|
||||
raise ValueError("From email is empty")
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{settings.from_name} <{settings.from_email}>"
|
||||
msg["To"] = to_email
|
||||
msg.set_content(text_body)
|
||||
|
||||
ssl_context = _build_context(settings.verify_tls)
|
||||
if settings.use_ssl:
|
||||
with smtplib.SMTP_SSL(
|
||||
settings.smtp_host,
|
||||
settings.smtp_port,
|
||||
timeout=settings.timeout_seconds,
|
||||
context=ssl_context,
|
||||
) as smtp:
|
||||
if settings.smtp_username:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password or "")
|
||||
smtp.send_message(msg)
|
||||
return
|
||||
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=settings.timeout_seconds) as smtp:
|
||||
smtp.ehlo()
|
||||
if settings.use_starttls:
|
||||
smtp.starttls(context=ssl_context)
|
||||
smtp.ehlo()
|
||||
if settings.smtp_username:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password or "")
|
||||
smtp.send_message(msg)
|
||||
@@ -0,0 +1,237 @@
|
||||
import json
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from app.services.calculations import minutes_to_hhmm
|
||||
from app.services.targets import monday_of
|
||||
|
||||
|
||||
def create_excel_export(rows: list[dict], week_summaries: list[dict], totals: dict, title: str) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "Tage"
|
||||
|
||||
headers = [
|
||||
"Datum",
|
||||
"Wochentag",
|
||||
"KW",
|
||||
"Start",
|
||||
"Ende",
|
||||
"Pause (min)",
|
||||
"Brutto",
|
||||
"Netto",
|
||||
"Stundenausgleich",
|
||||
"Sonderstatus",
|
||||
"Wochen-Soll",
|
||||
"Wochen-Delta",
|
||||
"Notiz",
|
||||
]
|
||||
sheet.append(headers)
|
||||
|
||||
for row in rows:
|
||||
sheet.append(
|
||||
[
|
||||
row["date"].isoformat(),
|
||||
row["weekday_name"],
|
||||
row["iso_week"],
|
||||
row["start_time"] or "",
|
||||
row["end_time"] or "",
|
||||
row["break_minutes"],
|
||||
minutes_to_hhmm(row["gross_minutes"]),
|
||||
minutes_to_hhmm(row["net_minutes"]),
|
||||
minutes_to_hhmm(row["overtime_adjustment_minutes"]),
|
||||
row["special_status_label"] or "",
|
||||
minutes_to_hhmm(row["weekly_target_minutes"]),
|
||||
minutes_to_hhmm(row["weekly_delta_minutes"]),
|
||||
row["notes"] or "",
|
||||
]
|
||||
)
|
||||
|
||||
for col in ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M"]:
|
||||
sheet.column_dimensions[col].width = 16
|
||||
|
||||
summary = workbook.create_sheet("Wochen")
|
||||
summary_headers = ["KW-Start", "KW-Ende", "Ist", "Soll", "Delta"]
|
||||
summary.append(summary_headers)
|
||||
|
||||
for item in week_summaries:
|
||||
summary.append(
|
||||
[
|
||||
item["week_start"].isoformat(),
|
||||
item["week_end"].isoformat(),
|
||||
minutes_to_hhmm(item["ist_minutes"]),
|
||||
minutes_to_hhmm(item["soll_minutes"]),
|
||||
minutes_to_hhmm(item["delta_minutes"]),
|
||||
]
|
||||
)
|
||||
|
||||
summary.append([])
|
||||
summary.append(["Gesamt", "", minutes_to_hhmm(totals["ist_minutes"]), "", minutes_to_hhmm(totals["delta_minutes"])])
|
||||
|
||||
meta = workbook.create_sheet("Meta")
|
||||
meta.append([title])
|
||||
meta.append([f"Zeitraum: {totals['from_date'].isoformat()} bis {totals['to_date'].isoformat()}"])
|
||||
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def create_pdf_export(rows: list[dict], week_summaries: list[dict], totals: dict, title: str) -> bytes:
|
||||
output = BytesIO()
|
||||
pdf = canvas.Canvas(output, pagesize=landscape(A4))
|
||||
width, height = landscape(A4)
|
||||
|
||||
y = height - 35
|
||||
pdf.setFont("Helvetica-Bold", 13)
|
||||
pdf.drawString(24, y, title)
|
||||
y -= 18
|
||||
|
||||
pdf.setFont("Helvetica", 10)
|
||||
pdf.drawString(24, y, f"Zeitraum: {totals['from_date'].isoformat()} bis {totals['to_date'].isoformat()}")
|
||||
y -= 24
|
||||
|
||||
pdf.setFont("Helvetica-Bold", 8)
|
||||
pdf.drawString(24, y, "Datum")
|
||||
pdf.drawString(88, y, "Tag")
|
||||
pdf.drawString(124, y, "KW")
|
||||
pdf.drawString(154, y, "Start")
|
||||
pdf.drawString(198, y, "Ende")
|
||||
pdf.drawString(242, y, "Pause")
|
||||
pdf.drawString(286, y, "Brutto")
|
||||
pdf.drawString(338, y, "Netto")
|
||||
pdf.drawString(390, y, "Ausgl.")
|
||||
pdf.drawString(436, y, "Status")
|
||||
pdf.drawString(490, y, "Soll")
|
||||
pdf.drawString(542, y, "W-Delta")
|
||||
pdf.drawString(610, y, "Notiz")
|
||||
y -= 12
|
||||
|
||||
pdf.setFont("Helvetica", 8)
|
||||
for row in rows:
|
||||
if y < 40:
|
||||
pdf.showPage()
|
||||
y = height - 30
|
||||
pdf.setFont("Helvetica", 8)
|
||||
|
||||
note = (row["notes"] or "").strip()
|
||||
if len(note) > 18:
|
||||
note = f"{note[:15]}..."
|
||||
|
||||
pdf.drawString(24, y, row["date"].isoformat())
|
||||
pdf.drawString(88, y, row["weekday_short"])
|
||||
pdf.drawString(124, y, str(row["iso_week"]))
|
||||
pdf.drawString(154, y, row["start_time"] or "-")
|
||||
pdf.drawString(198, y, row["end_time"] or "-")
|
||||
pdf.drawString(242, y, str(row["break_minutes"]))
|
||||
pdf.drawString(286, y, minutes_to_hhmm(row["gross_minutes"]))
|
||||
pdf.drawString(338, y, minutes_to_hhmm(row["net_minutes"]))
|
||||
pdf.drawString(390, y, minutes_to_hhmm(row["overtime_adjustment_minutes"]))
|
||||
pdf.drawString(436, y, row["special_status_label"] or "-")
|
||||
pdf.drawString(490, y, minutes_to_hhmm(row["weekly_target_minutes"]))
|
||||
pdf.drawString(542, y, minutes_to_hhmm(row["weekly_delta_minutes"]))
|
||||
pdf.drawString(610, y, note)
|
||||
y -= 11
|
||||
|
||||
y -= 12
|
||||
pdf.setFont("Helvetica-Bold", 10)
|
||||
pdf.drawString(24, y, "Wochenzusammenfassung")
|
||||
y -= 14
|
||||
|
||||
pdf.setFont("Helvetica", 9)
|
||||
for item in week_summaries:
|
||||
if y < 40:
|
||||
pdf.showPage()
|
||||
y = height - 30
|
||||
pdf.setFont("Helvetica", 9)
|
||||
|
||||
line = (
|
||||
f"{item['week_start'].isoformat()} - {item['week_end'].isoformat()} | "
|
||||
f"Ist {minutes_to_hhmm(item['ist_minutes'])} | "
|
||||
f"Soll {minutes_to_hhmm(item['soll_minutes'])} | "
|
||||
f"Delta {minutes_to_hhmm(item['delta_minutes'])}"
|
||||
)
|
||||
pdf.drawString(24, y, line)
|
||||
y -= 12
|
||||
|
||||
y -= 10
|
||||
pdf.setFont("Helvetica-Bold", 10)
|
||||
pdf.drawString(
|
||||
24,
|
||||
y,
|
||||
f"Gesamt Ist: {minutes_to_hhmm(totals['ist_minutes'])} | Gesamt Delta: {minutes_to_hhmm(totals['delta_minutes'])}",
|
||||
)
|
||||
|
||||
pdf.save()
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def create_backup_export(payload: dict) -> bytes:
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
|
||||
|
||||
def build_export_rows(
|
||||
days: list[date],
|
||||
entries_by_date: dict,
|
||||
week_target_map: dict[date, int],
|
||||
week_ist_map: dict[date, int],
|
||||
week_delta_map: dict[date, int],
|
||||
special_status_map: dict[date, str] | None = None,
|
||||
overtime_adjustment_map: dict[date, int] | None = None,
|
||||
) -> list[dict]:
|
||||
weekday_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||
weekday_short = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||
|
||||
special_status_map = special_status_map or {}
|
||||
overtime_adjustment_map = overtime_adjustment_map or {}
|
||||
special_status_labels = {
|
||||
"holiday": "Feiertag",
|
||||
"sick": "Krankheit",
|
||||
}
|
||||
|
||||
rows: list[dict] = []
|
||||
for day in days:
|
||||
entry = entries_by_date.get(day)
|
||||
week_start = monday_of(day)
|
||||
weekly_target = week_target_map[week_start]
|
||||
weekly_delta = week_delta_map[week_start]
|
||||
|
||||
if entry:
|
||||
gross = entry.end_minutes - entry.start_minutes
|
||||
net = gross - entry.break_minutes
|
||||
start_time = f"{entry.start_minutes // 60:02d}:{entry.start_minutes % 60:02d}"
|
||||
end_time = f"{entry.end_minutes // 60:02d}:{entry.end_minutes % 60:02d}"
|
||||
break_minutes = entry.break_minutes
|
||||
notes = entry.notes
|
||||
else:
|
||||
gross = 0
|
||||
net = 0
|
||||
start_time = None
|
||||
end_time = None
|
||||
break_minutes = 0
|
||||
notes = None
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"date": day,
|
||||
"weekday_name": weekday_names[day.weekday()],
|
||||
"weekday_short": weekday_short[day.weekday()],
|
||||
"iso_week": day.isocalendar()[1],
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"break_minutes": break_minutes,
|
||||
"gross_minutes": gross,
|
||||
"net_minutes": net,
|
||||
"overtime_adjustment_minutes": overtime_adjustment_map.get(day, 0),
|
||||
"special_status_label": special_status_labels.get(special_status_map.get(day, "")),
|
||||
"weekly_target_minutes": weekly_target,
|
||||
"weekly_delta_minutes": weekly_delta,
|
||||
"notes": notes,
|
||||
}
|
||||
)
|
||||
|
||||
return rows
|
||||
@@ -0,0 +1,712 @@
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
AutoEntrySkip,
|
||||
ImportPreview,
|
||||
OvertimeAdjustment,
|
||||
SpecialDayStatus,
|
||||
TimeEntry,
|
||||
User,
|
||||
VacationPeriod,
|
||||
WeeklyTargetRule,
|
||||
)
|
||||
from app.services.auto_entries import (
|
||||
ENTRY_MODE_AUTO_UNTIL_TODAY,
|
||||
ENTRY_MODE_MANUAL,
|
||||
delete_future_auto_entries,
|
||||
)
|
||||
from app.services.calculations import compute_net_minutes
|
||||
from app.services.public_holidays import normalize_german_state_code
|
||||
from app.services.security import utc_now
|
||||
from app.services.targets import ensure_user_has_default_target_rule
|
||||
from app.services.workdays import serialize_working_days
|
||||
|
||||
CURRENT_BACKUP_VERSION = 2
|
||||
SUPPORTED_BACKUP_VERSIONS = {1, 2}
|
||||
IMPORT_MODE_MERGE = "merge"
|
||||
IMPORT_MODE_REPLACE = "replace_user_data"
|
||||
IMPORT_PREVIEW_TTL_HOURS = 24
|
||||
MAX_BACKUP_BYTES = 5 * 1024 * 1024
|
||||
SPECIAL_STATUS_VALUES = {"holiday", "sick"}
|
||||
PREFERRED_HOME_VIEWS = {"week", "month"}
|
||||
PREFERRED_MONTH_VIEWS = {"flat", "weeks"}
|
||||
BREAK_RULE_MODES = {"manual", "auto"}
|
||||
|
||||
|
||||
class BackupImportError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def supported_import_modes() -> set[str]:
|
||||
return {IMPORT_MODE_MERGE, IMPORT_MODE_REPLACE}
|
||||
|
||||
|
||||
def _require_mapping(value: Any, *, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise BackupImportError(f"{label} ist nicht korrekt aufgebaut.")
|
||||
return value
|
||||
|
||||
|
||||
def _require_list(value: Any, *, label: str) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise BackupImportError(f"{label} ist nicht korrekt aufgebaut.")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_date(value: Any, *, label: str) -> date:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise BackupImportError(f"{label} fehlt oder ist ungültig.")
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise BackupImportError(f"{label} hat kein gültiges Datum.") from exc
|
||||
|
||||
|
||||
def _parse_datetime(value: Any, *, label: str) -> str | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise BackupImportError(f"{label} hat kein gültiges Datum.")
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise BackupImportError(f"{label} hat kein gültiges Datum.") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _parse_int(value: Any, *, label: str, minimum: int | None = None) -> int:
|
||||
if not isinstance(value, int):
|
||||
raise BackupImportError(f"{label} ist keine ganze Zahl.")
|
||||
if minimum is not None and value < minimum:
|
||||
raise BackupImportError(f"{label} ist zu klein.")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_optional_int(value: Any, *, label: str, minimum: int | None = None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _parse_int(value, label=label, minimum=minimum)
|
||||
|
||||
|
||||
def _parse_bool(value: Any, *, label: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise BackupImportError(f"{label} muss true oder false sein.")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_optional_text(value: Any, *, label: str) -> str | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise BackupImportError(f"{label} ist ungültig.")
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
settings_value = payload.get("settings")
|
||||
if settings_value is None:
|
||||
user_section = payload.get("user")
|
||||
if isinstance(user_section, dict):
|
||||
settings_value = user_section.get("settings")
|
||||
settings_data = _require_mapping(settings_value, label="Backup-Einstellungen")
|
||||
|
||||
working_days_raw = settings_data.get("working_days")
|
||||
if not isinstance(working_days_raw, list) or not working_days_raw:
|
||||
raise BackupImportError("Die relevanten Arbeitstage im Backup sind ungültig.")
|
||||
working_days: list[int] = []
|
||||
for item in working_days_raw:
|
||||
if not isinstance(item, int) or item < 0 or item > 6:
|
||||
raise BackupImportError("Die relevanten Arbeitstage im Backup sind ungültig.")
|
||||
if item not in working_days:
|
||||
working_days.append(item)
|
||||
if not working_days:
|
||||
raise BackupImportError("Im Backup ist kein relevanter Arbeitstag hinterlegt.")
|
||||
|
||||
preferred_home_view = settings_data.get("preferred_home_view", "week")
|
||||
if preferred_home_view not in PREFERRED_HOME_VIEWS:
|
||||
preferred_home_view = "week"
|
||||
|
||||
preferred_month_view_mode = settings_data.get("preferred_month_view_mode", "flat")
|
||||
if preferred_month_view_mode not in PREFERRED_MONTH_VIEWS:
|
||||
preferred_month_view_mode = "flat"
|
||||
|
||||
entry_mode = settings_data.get("entry_mode", ENTRY_MODE_MANUAL)
|
||||
if entry_mode == "auto":
|
||||
entry_mode = ENTRY_MODE_AUTO_UNTIL_TODAY
|
||||
if entry_mode not in {ENTRY_MODE_MANUAL, ENTRY_MODE_AUTO_UNTIL_TODAY}:
|
||||
raise BackupImportError("Der Erfassungsmodus im Backup ist ungültig.")
|
||||
|
||||
federal_state = None
|
||||
if settings_data.get("federal_state"):
|
||||
federal_state = normalize_german_state_code(str(settings_data.get("federal_state")))
|
||||
if federal_state is None:
|
||||
raise BackupImportError("Das Bundesland im Backup ist ungültig.")
|
||||
|
||||
overtime_start_date = None
|
||||
if settings_data.get("overtime_start_date"):
|
||||
overtime_start_date = _parse_date(settings_data.get("overtime_start_date"), label="Überstunden-Startdatum")
|
||||
|
||||
workhours_counter_start_date = None
|
||||
if settings_data.get("workhours_counter_start_date"):
|
||||
workhours_counter_start_date = _parse_date(
|
||||
settings_data.get("workhours_counter_start_date"),
|
||||
label="Arbeitsstunden-Counter Startdatum",
|
||||
)
|
||||
|
||||
workhours_counter_end_date = None
|
||||
if settings_data.get("workhours_counter_end_date"):
|
||||
workhours_counter_end_date = _parse_date(
|
||||
settings_data.get("workhours_counter_end_date"),
|
||||
label="Arbeitsstunden-Counter Enddatum",
|
||||
)
|
||||
|
||||
return {
|
||||
"weekly_target_minutes": _parse_int(settings_data.get("weekly_target_minutes", 1500), label="Wochenstunden", minimum=1),
|
||||
"preferred_home_view": preferred_home_view,
|
||||
"preferred_month_view_mode": preferred_month_view_mode,
|
||||
"entry_mode": entry_mode,
|
||||
"working_days": sorted(working_days),
|
||||
"count_vacation_as_worktime": _parse_bool(
|
||||
settings_data.get("count_vacation_as_worktime", False),
|
||||
label="Urlaubstage-wie-Arbeitstage",
|
||||
),
|
||||
"count_holiday_as_worktime": _parse_bool(
|
||||
settings_data.get("count_holiday_as_worktime", False),
|
||||
label="Feiertage-wie-Arbeitstage",
|
||||
),
|
||||
"count_sick_as_worktime": _parse_bool(
|
||||
settings_data.get("count_sick_as_worktime", False),
|
||||
label="Kranktage-wie-Arbeitstage",
|
||||
),
|
||||
"automatic_break_rules_enabled": _parse_bool(
|
||||
settings_data.get("automatic_break_rules_enabled", False),
|
||||
label="Automatische Pausenregel",
|
||||
),
|
||||
"default_break_minutes": _parse_int(
|
||||
settings_data.get("default_break_minutes", 0),
|
||||
label="Tägliche Pause",
|
||||
minimum=0,
|
||||
),
|
||||
"overtime_start_date": overtime_start_date.isoformat() if overtime_start_date else None,
|
||||
"overtime_expiry_days": _parse_optional_int(
|
||||
settings_data.get("overtime_expiry_days"),
|
||||
label="Überstunden-Verfall",
|
||||
minimum=1,
|
||||
),
|
||||
"expire_negative_overtime": _parse_bool(
|
||||
settings_data.get("expire_negative_overtime", False),
|
||||
label="Negative Stunden verfallen",
|
||||
),
|
||||
"vacation_days_total": _parse_int(
|
||||
settings_data.get("vacation_days_total", 0),
|
||||
label="Urlaubstage gesamt",
|
||||
minimum=0,
|
||||
),
|
||||
"vacation_show_in_header": _parse_bool(
|
||||
settings_data.get("vacation_show_in_header", True),
|
||||
label="Urlaub im Header anzeigen",
|
||||
),
|
||||
"workhours_counter_enabled": _parse_bool(
|
||||
settings_data.get("workhours_counter_enabled", False),
|
||||
label="Arbeitsstunden-Counter aktiviert",
|
||||
),
|
||||
"workhours_counter_show_in_header": _parse_bool(
|
||||
settings_data.get("workhours_counter_show_in_header", False),
|
||||
label="Arbeitsstunden-Counter im Header anzeigen",
|
||||
),
|
||||
"workhours_counter_start_date": (
|
||||
workhours_counter_start_date.isoformat() if workhours_counter_start_date else None
|
||||
),
|
||||
"workhours_counter_end_date": (
|
||||
workhours_counter_end_date.isoformat() if workhours_counter_end_date else None
|
||||
),
|
||||
"workhours_counter_manual_offset_minutes": _parse_int(
|
||||
settings_data.get("workhours_counter_manual_offset_minutes", 0),
|
||||
label="Zusatzstunden",
|
||||
minimum=0,
|
||||
),
|
||||
"workhours_counter_target_minutes": _parse_optional_int(
|
||||
settings_data.get("workhours_counter_target_minutes"),
|
||||
label="Arbeitsstunden-Ziel",
|
||||
minimum=1,
|
||||
),
|
||||
"workhours_counter_target_email_enabled": _parse_bool(
|
||||
settings_data.get("workhours_counter_target_email_enabled", False),
|
||||
label="Counter-Zielwarnung per E-Mail",
|
||||
),
|
||||
"federal_state": federal_state,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_weekly_target_rules(items: list[Any]) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for item in _require_list(items, label="Wochenziel-Regeln"):
|
||||
row = _require_mapping(item, label="Wochenziel-Regel")
|
||||
effective_from = _parse_date(row.get("effective_from"), label="Wochenziel Startdatum").isoformat()
|
||||
if effective_from in seen:
|
||||
continue
|
||||
seen.add(effective_from)
|
||||
normalized.append(
|
||||
{
|
||||
"effective_from": effective_from,
|
||||
"weekly_target_minutes": _parse_int(
|
||||
row.get("weekly_target_minutes"),
|
||||
label="Wochenziel in Minuten",
|
||||
minimum=1,
|
||||
),
|
||||
}
|
||||
)
|
||||
normalized.sort(key=lambda item: item["effective_from"])
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_time_entries(items: list[Any]) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for item in _require_list(items, label="Arbeitszeiteinträge"):
|
||||
row = _require_mapping(item, label="Arbeitszeiteintrag")
|
||||
entry_date = _parse_date(row.get("date"), label="Arbeitszeiteintrag Datum").isoformat()
|
||||
if entry_date in seen:
|
||||
continue
|
||||
seen.add(entry_date)
|
||||
start_minutes = _parse_int(row.get("start_minutes"), label="Arbeitsbeginn", minimum=0)
|
||||
end_minutes = _parse_int(row.get("end_minutes"), label="Arbeitsende", minimum=0)
|
||||
break_minutes = _parse_int(row.get("break_minutes", 0), label="Pause", minimum=0)
|
||||
break_rule_mode = row.get("break_rule_mode", "manual")
|
||||
if break_rule_mode not in BREAK_RULE_MODES:
|
||||
break_rule_mode = "manual"
|
||||
compute_net_minutes(start_minutes, end_minutes, break_minutes)
|
||||
normalized.append(
|
||||
{
|
||||
"date": entry_date,
|
||||
"start_minutes": start_minutes,
|
||||
"end_minutes": end_minutes,
|
||||
"break_minutes": break_minutes,
|
||||
"break_rule_mode": break_rule_mode,
|
||||
"notes": _parse_optional_text(row.get("notes"), label="Notiz"),
|
||||
}
|
||||
)
|
||||
normalized.sort(key=lambda item: item["date"])
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_vacation_periods(items: list[Any]) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str, bool, str | None]] = set()
|
||||
for item in _require_list(items, label="Urlaubszeiträume"):
|
||||
row = _require_mapping(item, label="Urlaubszeitraum")
|
||||
start_date = _parse_date(row.get("start_date"), label="Urlaubsbeginn")
|
||||
end_date = _parse_date(row.get("end_date"), label="Urlaubsende")
|
||||
if end_date < start_date:
|
||||
raise BackupImportError("Ein Urlaubszeitraum endet vor seinem Startdatum.")
|
||||
include_weekends = _parse_bool(row.get("include_weekends", False), label="Wochenenden einschließen")
|
||||
notes = _parse_optional_text(row.get("notes"), label="Urlaubsnotiz")
|
||||
key = (start_date.isoformat(), end_date.isoformat(), include_weekends, notes)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
normalized.append(
|
||||
{
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": end_date.isoformat(),
|
||||
"include_weekends": include_weekends,
|
||||
"notes": notes,
|
||||
}
|
||||
)
|
||||
normalized.sort(key=lambda item: (item["start_date"], item["end_date"]))
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_special_day_statuses(items: list[Any]) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for item in _require_list(items, label="Sondertage"):
|
||||
row = _require_mapping(item, label="Sondertag")
|
||||
status_date = _parse_date(row.get("date"), label="Sondertag Datum").isoformat()
|
||||
if status_date in seen:
|
||||
continue
|
||||
seen.add(status_date)
|
||||
status_value = row.get("status")
|
||||
if status_value not in SPECIAL_STATUS_VALUES:
|
||||
raise BackupImportError("Ein Sondertag im Backup hat einen ungültigen Status.")
|
||||
normalized.append(
|
||||
{
|
||||
"date": status_date,
|
||||
"status": status_value,
|
||||
"notes": _parse_optional_text(row.get("notes"), label="Sondertag-Notiz"),
|
||||
}
|
||||
)
|
||||
normalized.sort(key=lambda item: item["date"])
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_overtime_adjustments(items: list[Any]) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for item in _require_list(items, label="Stundenausgleich"):
|
||||
row = _require_mapping(item, label="Stundenausgleich-Eintrag")
|
||||
adjustment_date = _parse_date(row.get("date"), label="Stundenausgleich Datum").isoformat()
|
||||
if adjustment_date in seen:
|
||||
continue
|
||||
seen.add(adjustment_date)
|
||||
normalized.append(
|
||||
{
|
||||
"date": adjustment_date,
|
||||
"minutes": _parse_int(row.get("minutes"), label="Stundenausgleich Minuten"),
|
||||
"notes": _parse_optional_text(row.get("notes"), label="Stundenausgleich-Notiz"),
|
||||
}
|
||||
)
|
||||
normalized.sort(key=lambda item: item["date"])
|
||||
return normalized
|
||||
|
||||
|
||||
def load_backup_payload_from_bytes(payload_bytes: bytes) -> dict[str, Any]:
|
||||
if not payload_bytes:
|
||||
raise BackupImportError("Die Backup-Datei ist leer.")
|
||||
if len(payload_bytes) > MAX_BACKUP_BYTES:
|
||||
raise BackupImportError("Die Backup-Datei ist zu groß.")
|
||||
try:
|
||||
raw = json.loads(payload_bytes.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BackupImportError("Die Backup-Datei ist kein gültiges JSON.") from exc
|
||||
|
||||
payload = _require_mapping(raw, label="Backup-Datei")
|
||||
version = payload.get("backup_version")
|
||||
if version not in SUPPORTED_BACKUP_VERSIONS:
|
||||
raise BackupImportError("Diese Backup-Version wird noch nicht unterstützt.")
|
||||
|
||||
normalized = {
|
||||
"backup_version": version,
|
||||
"source_app_name": str(payload.get("app_name") or "Stundenfuchs"),
|
||||
"source_app_version": str(payload.get("app_version") or "unbekannt"),
|
||||
"exported_at": _parse_datetime(payload.get("exported_at"), label="Exportdatum"),
|
||||
"settings": _normalize_settings(payload),
|
||||
"weekly_target_rules": _normalize_weekly_target_rules(payload.get("weekly_target_rules")),
|
||||
"time_entries": _normalize_time_entries(payload.get("time_entries")),
|
||||
"vacation_periods": _normalize_vacation_periods(payload.get("vacation_periods")),
|
||||
"special_day_statuses": _normalize_special_day_statuses(payload.get("special_day_statuses")),
|
||||
"overtime_adjustments": _normalize_overtime_adjustments(payload.get("overtime_adjustments")),
|
||||
}
|
||||
return normalized
|
||||
|
||||
|
||||
def summarize_backup_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
settings_data = payload["settings"]
|
||||
return {
|
||||
"backup_version": payload["backup_version"],
|
||||
"source_app_name": payload["source_app_name"],
|
||||
"source_app_version": payload["source_app_version"],
|
||||
"exported_at": payload["exported_at"],
|
||||
"settings_summary": {
|
||||
"entry_mode": settings_data["entry_mode"],
|
||||
"weekly_target_minutes": settings_data["weekly_target_minutes"],
|
||||
"working_days": settings_data["working_days"],
|
||||
"federal_state": settings_data["federal_state"],
|
||||
"vacation_days_total": settings_data["vacation_days_total"],
|
||||
"workhours_counter_enabled": settings_data["workhours_counter_enabled"],
|
||||
},
|
||||
"counts": {
|
||||
"weekly_target_rules": len(payload["weekly_target_rules"]),
|
||||
"time_entries": len(payload["time_entries"]),
|
||||
"vacation_periods": len(payload["vacation_periods"]),
|
||||
"special_day_statuses": len(payload["special_day_statuses"]),
|
||||
"overtime_adjustments": len(payload["overtime_adjustments"]),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_import_preview(*, db: Session, user: User, payload: dict[str, Any], mode: str) -> dict[str, Any]:
|
||||
if mode not in supported_import_modes():
|
||||
raise BackupImportError("Ungültiger Importmodus.")
|
||||
|
||||
existing_time_entry_dates = set(
|
||||
db.execute(select(TimeEntry.date).where(TimeEntry.user_id == user.id)).scalars().all()
|
||||
)
|
||||
existing_special_dates = set(
|
||||
db.execute(select(SpecialDayStatus.date).where(SpecialDayStatus.user_id == user.id)).scalars().all()
|
||||
)
|
||||
existing_adjustment_dates = set(
|
||||
db.execute(select(OvertimeAdjustment.date).where(OvertimeAdjustment.user_id == user.id)).scalars().all()
|
||||
)
|
||||
existing_rule_dates = set(
|
||||
db.execute(select(WeeklyTargetRule.effective_from).where(WeeklyTargetRule.user_id == user.id)).scalars().all()
|
||||
)
|
||||
existing_vacation_keys = set(
|
||||
db.execute(
|
||||
select(
|
||||
VacationPeriod.start_date,
|
||||
VacationPeriod.end_date,
|
||||
VacationPeriod.include_weekends,
|
||||
VacationPeriod.notes,
|
||||
).where(VacationPeriod.user_id == user.id)
|
||||
).all()
|
||||
)
|
||||
|
||||
conflicts = {
|
||||
"time_entries": sum(1 for row in payload["time_entries"] if date.fromisoformat(row["date"]) in existing_time_entry_dates),
|
||||
"special_day_statuses": sum(
|
||||
1 for row in payload["special_day_statuses"] if date.fromisoformat(row["date"]) in existing_special_dates
|
||||
),
|
||||
"overtime_adjustments": sum(
|
||||
1 for row in payload["overtime_adjustments"] if date.fromisoformat(row["date"]) in existing_adjustment_dates
|
||||
),
|
||||
"weekly_target_rules": sum(
|
||||
1 for row in payload["weekly_target_rules"] if date.fromisoformat(row["effective_from"]) in existing_rule_dates
|
||||
),
|
||||
"vacation_periods": sum(
|
||||
1
|
||||
for row in payload["vacation_periods"]
|
||||
if (
|
||||
date.fromisoformat(row["start_date"]),
|
||||
date.fromisoformat(row["end_date"]),
|
||||
row["include_weekends"],
|
||||
row["notes"],
|
||||
)
|
||||
in existing_vacation_keys
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
**summarize_backup_payload(payload),
|
||||
"mode": mode,
|
||||
"mode_label": "Zusammenführen" if mode == IMPORT_MODE_MERGE else "Alle bisherigen Daten ersetzen",
|
||||
"conflicts": conflicts,
|
||||
}
|
||||
|
||||
|
||||
def cleanup_import_previews(*, db: Session, user_id: str | None = None) -> None:
|
||||
cutoff = utc_now() - timedelta(hours=IMPORT_PREVIEW_TTL_HOURS)
|
||||
stmt = delete(ImportPreview).where(ImportPreview.created_at < cutoff)
|
||||
if user_id:
|
||||
stmt = stmt.where(ImportPreview.user_id == user_id)
|
||||
db.execute(stmt)
|
||||
|
||||
|
||||
def _preview_created_at(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def create_import_preview_record(*, db: Session, user: User, payload: dict[str, Any], mode: str) -> ImportPreview:
|
||||
cleanup_import_previews(db=db, user_id=user.id)
|
||||
db.execute(delete(ImportPreview).where(ImportPreview.user_id == user.id))
|
||||
preview = ImportPreview(user_id=user.id, mode=mode, payload_json=json.dumps(payload, ensure_ascii=False))
|
||||
db.add(preview)
|
||||
db.flush()
|
||||
return preview
|
||||
|
||||
|
||||
def get_import_preview_record(*, db: Session, user: User, preview_id: str) -> ImportPreview | None:
|
||||
stmt = select(ImportPreview).where(ImportPreview.id == preview_id, ImportPreview.user_id == user.id)
|
||||
preview = db.execute(stmt).scalar_one_or_none()
|
||||
if preview is None:
|
||||
return None
|
||||
if _preview_created_at(preview.created_at) < utc_now() - timedelta(hours=IMPORT_PREVIEW_TTL_HOURS):
|
||||
db.delete(preview)
|
||||
db.flush()
|
||||
return None
|
||||
return preview
|
||||
|
||||
|
||||
def parse_preview_payload(preview: ImportPreview) -> dict[str, Any]:
|
||||
return load_backup_payload_from_bytes(preview.payload_json.encode("utf-8"))
|
||||
|
||||
|
||||
def _apply_settings_from_backup(*, user: User, settings_data: dict[str, Any]) -> None:
|
||||
user.weekly_target_minutes = settings_data["weekly_target_minutes"]
|
||||
user.preferred_home_view = settings_data["preferred_home_view"]
|
||||
user.preferred_month_view_mode = settings_data["preferred_month_view_mode"]
|
||||
user.entry_mode = settings_data["entry_mode"]
|
||||
user.working_days_csv = serialize_working_days(settings_data["working_days"])
|
||||
user.count_vacation_as_worktime = settings_data["count_vacation_as_worktime"]
|
||||
user.count_holiday_as_worktime = settings_data["count_holiday_as_worktime"]
|
||||
user.count_sick_as_worktime = settings_data["count_sick_as_worktime"]
|
||||
user.automatic_break_rules_enabled = settings_data["automatic_break_rules_enabled"]
|
||||
user.default_break_minutes = settings_data["default_break_minutes"]
|
||||
user.overtime_start_date = date.fromisoformat(settings_data["overtime_start_date"]) if settings_data["overtime_start_date"] else None
|
||||
user.overtime_expiry_days = settings_data["overtime_expiry_days"]
|
||||
user.expire_negative_overtime = settings_data["expire_negative_overtime"]
|
||||
user.vacation_days_total = settings_data["vacation_days_total"]
|
||||
user.vacation_show_in_header = settings_data["vacation_show_in_header"]
|
||||
user.workhours_counter_enabled = settings_data["workhours_counter_enabled"]
|
||||
user.workhours_counter_show_in_header = settings_data["workhours_counter_show_in_header"]
|
||||
user.workhours_counter_start_date = (
|
||||
date.fromisoformat(settings_data["workhours_counter_start_date"])
|
||||
if settings_data["workhours_counter_start_date"]
|
||||
else None
|
||||
)
|
||||
user.workhours_counter_end_date = (
|
||||
date.fromisoformat(settings_data["workhours_counter_end_date"])
|
||||
if settings_data["workhours_counter_end_date"]
|
||||
else None
|
||||
)
|
||||
user.workhours_counter_manual_offset_minutes = settings_data["workhours_counter_manual_offset_minutes"]
|
||||
user.workhours_counter_target_minutes = settings_data["workhours_counter_target_minutes"]
|
||||
user.workhours_counter_target_email_enabled = settings_data["workhours_counter_target_email_enabled"]
|
||||
user.federal_state = settings_data["federal_state"]
|
||||
|
||||
|
||||
def clear_importable_user_data(*, db: Session, user_id: str) -> None:
|
||||
db.execute(delete(TimeEntry).where(TimeEntry.user_id == user_id))
|
||||
db.execute(delete(WeeklyTargetRule).where(WeeklyTargetRule.user_id == user_id))
|
||||
db.execute(delete(VacationPeriod).where(VacationPeriod.user_id == user_id))
|
||||
db.execute(delete(SpecialDayStatus).where(SpecialDayStatus.user_id == user_id))
|
||||
db.execute(delete(OvertimeAdjustment).where(OvertimeAdjustment.user_id == user_id))
|
||||
db.execute(delete(AutoEntrySkip).where(AutoEntrySkip.user_id == user_id))
|
||||
|
||||
|
||||
def execute_backup_import(*, db: Session, user: User, payload: dict[str, Any], mode: str) -> dict[str, Any]:
|
||||
if mode not in supported_import_modes():
|
||||
raise BackupImportError("Ungültiger Importmodus.")
|
||||
|
||||
created = {
|
||||
"weekly_target_rules": 0,
|
||||
"time_entries": 0,
|
||||
"vacation_periods": 0,
|
||||
"special_day_statuses": 0,
|
||||
"overtime_adjustments": 0,
|
||||
}
|
||||
skipped = {
|
||||
"weekly_target_rules": 0,
|
||||
"time_entries": 0,
|
||||
"vacation_periods": 0,
|
||||
"special_day_statuses": 0,
|
||||
"overtime_adjustments": 0,
|
||||
}
|
||||
|
||||
if mode == IMPORT_MODE_REPLACE:
|
||||
clear_importable_user_data(db=db, user_id=user.id)
|
||||
|
||||
_apply_settings_from_backup(user=user, settings_data=payload["settings"])
|
||||
|
||||
existing_rule_dates = set(
|
||||
db.execute(select(WeeklyTargetRule.effective_from).where(WeeklyTargetRule.user_id == user.id)).scalars().all()
|
||||
)
|
||||
existing_entry_dates = set(db.execute(select(TimeEntry.date).where(TimeEntry.user_id == user.id)).scalars().all())
|
||||
existing_vacation_keys = set(
|
||||
db.execute(
|
||||
select(
|
||||
VacationPeriod.start_date,
|
||||
VacationPeriod.end_date,
|
||||
VacationPeriod.include_weekends,
|
||||
VacationPeriod.notes,
|
||||
).where(VacationPeriod.user_id == user.id)
|
||||
).all()
|
||||
)
|
||||
existing_special_dates = set(
|
||||
db.execute(select(SpecialDayStatus.date).where(SpecialDayStatus.user_id == user.id)).scalars().all()
|
||||
)
|
||||
existing_adjustment_dates = set(
|
||||
db.execute(select(OvertimeAdjustment.date).where(OvertimeAdjustment.user_id == user.id)).scalars().all()
|
||||
)
|
||||
|
||||
for row in payload["weekly_target_rules"]:
|
||||
effective_from = date.fromisoformat(row["effective_from"])
|
||||
if mode == IMPORT_MODE_MERGE and effective_from in existing_rule_dates:
|
||||
skipped["weekly_target_rules"] += 1
|
||||
continue
|
||||
db.add(
|
||||
WeeklyTargetRule(
|
||||
user_id=user.id,
|
||||
effective_from=effective_from,
|
||||
weekly_target_minutes=row["weekly_target_minutes"],
|
||||
)
|
||||
)
|
||||
existing_rule_dates.add(effective_from)
|
||||
created["weekly_target_rules"] += 1
|
||||
|
||||
for row in payload["time_entries"]:
|
||||
entry_date = date.fromisoformat(row["date"])
|
||||
if mode == IMPORT_MODE_MERGE and entry_date in existing_entry_dates:
|
||||
skipped["time_entries"] += 1
|
||||
continue
|
||||
db.add(
|
||||
TimeEntry(
|
||||
user_id=user.id,
|
||||
date=entry_date,
|
||||
start_minutes=row["start_minutes"],
|
||||
end_minutes=row["end_minutes"],
|
||||
break_minutes=row["break_minutes"],
|
||||
break_rule_mode=row["break_rule_mode"],
|
||||
notes=row["notes"],
|
||||
)
|
||||
)
|
||||
existing_entry_dates.add(entry_date)
|
||||
created["time_entries"] += 1
|
||||
|
||||
for row in payload["vacation_periods"]:
|
||||
key = (
|
||||
date.fromisoformat(row["start_date"]),
|
||||
date.fromisoformat(row["end_date"]),
|
||||
row["include_weekends"],
|
||||
row["notes"],
|
||||
)
|
||||
if mode == IMPORT_MODE_MERGE and key in existing_vacation_keys:
|
||||
skipped["vacation_periods"] += 1
|
||||
continue
|
||||
db.add(
|
||||
VacationPeriod(
|
||||
user_id=user.id,
|
||||
start_date=key[0],
|
||||
end_date=key[1],
|
||||
include_weekends=key[2],
|
||||
notes=key[3],
|
||||
)
|
||||
)
|
||||
existing_vacation_keys.add(key)
|
||||
created["vacation_periods"] += 1
|
||||
|
||||
for row in payload["special_day_statuses"]:
|
||||
status_date = date.fromisoformat(row["date"])
|
||||
if mode == IMPORT_MODE_MERGE and status_date in existing_special_dates:
|
||||
skipped["special_day_statuses"] += 1
|
||||
continue
|
||||
db.add(
|
||||
SpecialDayStatus(
|
||||
user_id=user.id,
|
||||
date=status_date,
|
||||
status=row["status"],
|
||||
notes=row["notes"],
|
||||
)
|
||||
)
|
||||
existing_special_dates.add(status_date)
|
||||
created["special_day_statuses"] += 1
|
||||
|
||||
for row in payload["overtime_adjustments"]:
|
||||
adjustment_date = date.fromisoformat(row["date"])
|
||||
if mode == IMPORT_MODE_MERGE and adjustment_date in existing_adjustment_dates:
|
||||
skipped["overtime_adjustments"] += 1
|
||||
continue
|
||||
db.add(
|
||||
OvertimeAdjustment(
|
||||
user_id=user.id,
|
||||
date=adjustment_date,
|
||||
minutes=row["minutes"],
|
||||
notes=row["notes"],
|
||||
)
|
||||
)
|
||||
existing_adjustment_dates.add(adjustment_date)
|
||||
created["overtime_adjustments"] += 1
|
||||
|
||||
db.flush()
|
||||
ensure_user_has_default_target_rule(db, user)
|
||||
if user.entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY:
|
||||
removed_future_auto_entries = delete_future_auto_entries(db=db, user_id=user.id, after_date=date.today())
|
||||
else:
|
||||
removed_future_auto_entries = 0
|
||||
|
||||
return {
|
||||
"mode": mode,
|
||||
"created": created,
|
||||
"skipped": skipped,
|
||||
"removed_future_auto_entries": removed_future_auto_entries,
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import markdown as markdown_lib
|
||||
import bleach
|
||||
|
||||
SITE_CONTENT_IMPRESSUM = 'impressum'
|
||||
SITE_CONTENT_PRIVACY = 'datenschutz'
|
||||
|
||||
DEFAULT_SITE_CONTENT_MARKDOWN = {
|
||||
SITE_CONTENT_IMPRESSUM: """# Impressum
|
||||
|
||||
Bitte vor dem produktiven Einsatz im Admin-Bereich vollständig ausfüllen.
|
||||
|
||||
## Diensteanbieter
|
||||
|
||||
Firmenname / Name
|
||||
Straße und Hausnummer
|
||||
PLZ Ort
|
||||
Land
|
||||
|
||||
## Kontakt
|
||||
|
||||
E-Mail: [kontakt@example.com](mailto:kontakt@example.com)
|
||||
|
||||
## Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV
|
||||
|
||||
Name der verantwortlichen Person
|
||||
Straße und Hausnummer
|
||||
PLZ Ort
|
||||
Land
|
||||
""",
|
||||
SITE_CONTENT_PRIVACY: """# Datenschutzerklärung
|
||||
|
||||
## 1. Verantwortlicher
|
||||
|
||||
Bitte vor dem produktiven Einsatz im Admin-Bereich prüfen und anpassen.
|
||||
|
||||
Verantwortlich für die Verarbeitung personenbezogener Daten im Zusammenhang mit dieser Website und Anwendung ist:
|
||||
|
||||
Firmenname / Name
|
||||
Straße und Hausnummer
|
||||
PLZ Ort
|
||||
Land
|
||||
E-Mail: [kontakt@example.com](mailto:kontakt@example.com)
|
||||
|
||||
## 2. Allgemeines zur Datenverarbeitung
|
||||
|
||||
Ich verarbeite personenbezogene Daten nur, soweit dies zur Bereitstellung einer funktionsfähigen Website und Anwendung, zur Bearbeitung von Anfragen, zur Sicherheit des Dienstes sowie zur Erbringung der angebotenen Funktionen erforderlich ist.
|
||||
|
||||
## 3. Aufruf der Website
|
||||
|
||||
Beim Aufruf der Website werden technisch erforderliche Daten verarbeitet, um die Seite auszuliefern und die Stabilität und Sicherheit des Dienstes zu gewährleisten.
|
||||
|
||||
Dabei können insbesondere folgende Daten verarbeitet werden:
|
||||
|
||||
- IP-Adresse
|
||||
- Datum und Uhrzeit des Abrufs
|
||||
- aufgerufene Seite bzw. Ressource
|
||||
- Informationen über Browser und Betriebssystem
|
||||
- Referrer-Informationen
|
||||
- Protokolldaten zu Sicherheits- und Fehlervorgängen
|
||||
|
||||
Die Verarbeitung erfolgt zur technischen Bereitstellung, Systemsicherheit und Missbrauchserkennung.
|
||||
|
||||
## 4. Registrierung und Benutzerkonto
|
||||
|
||||
Wenn du ein Benutzerkonto anlegst, verarbeite ich die von dir angegebenen Registrierungsdaten, insbesondere:
|
||||
|
||||
- E-Mail-Adresse
|
||||
- Passwort in gehashter Form
|
||||
- von dir hinterlegte Einstellungen innerhalb der Anwendung
|
||||
|
||||
Die Verarbeitung erfolgt zum Zweck der Einrichtung und Verwaltung deines Benutzerkontos sowie zur Nutzung der Funktionen von Stundenfuchs.
|
||||
|
||||
## 5. Nutzung der Anwendung
|
||||
|
||||
Im Rahmen der Nutzung von Stundenfuchs verarbeite ich die von dir eingegebenen oder erzeugten Inhalte, insbesondere:
|
||||
|
||||
- Arbeitszeiteinträge
|
||||
- Pausenangaben
|
||||
- Urlaubs-, Krankheits- und Feiertagseinträge
|
||||
- Stundenausgleich
|
||||
- Einstellungen zu Wochenstunden, relevanten Arbeitstagen und Auswertungen
|
||||
- Backup-, Export- und Importdaten
|
||||
- Angaben im Arbeitsstunden-Counter
|
||||
|
||||
Diese Daten werden verarbeitet, um dir die Funktionen der Anwendung bereitzustellen.
|
||||
|
||||
## 6. Anmeldung, Sitzungen und Sicherheit
|
||||
|
||||
Zur Anmeldung und sicheren Nutzung der Anwendung werden technisch notwendige Sitzungsdaten verarbeitet. Außerdem können sicherheitsrelevante Daten verarbeitet werden, insbesondere zur:
|
||||
|
||||
- Login-Verwaltung
|
||||
- Erkennung missbräuchlicher Zugriffe
|
||||
- Durchsetzung von Sicherheitsmaßnahmen
|
||||
- Begrenzung fehlerhafter Login- oder Formularversuche
|
||||
|
||||
## 7. E-Mail-Funktionen
|
||||
|
||||
Im Zusammenhang mit der Nutzung von Stundenfuchs können E-Mails versendet werden, insbesondere für:
|
||||
|
||||
- E-Mail-Bestätigung
|
||||
- Passwort-Reset
|
||||
- sicherheitsrelevante Hinweise
|
||||
- Benachrichtigungen innerhalb der Anwendung
|
||||
- Kontaktanfragen bzw. Tickets
|
||||
|
||||
Dafür werden insbesondere E-Mail-Adresse und die jeweils zur Nachricht erforderlichen Metadaten verarbeitet.
|
||||
|
||||
## 8. Zwei-Faktor-Authentifizierung
|
||||
|
||||
Wenn du die Zwei-Faktor-Authentifizierung aktivierst, werden die dafür erforderlichen Sicherheitsdaten verarbeitet, um die zusätzliche Anmeldung per Authenticator-App zu ermöglichen.
|
||||
|
||||
## 9. Kontaktformular und Ticketsystem
|
||||
|
||||
Wenn du das Kontaktformular nutzt oder ein Ticket erstellst, verarbeite ich die von dir übermittelten Angaben, insbesondere:
|
||||
|
||||
- Name
|
||||
- E-Mail-Adresse
|
||||
- Kategorie der Anfrage
|
||||
- Betreff
|
||||
- Nachricht
|
||||
- technische Missbrauchsschutzdaten
|
||||
|
||||
Die Verarbeitung erfolgt zur Bearbeitung deiner Anfrage, zur Kommunikation mit dir sowie zur Abwehr von Missbrauch und Spam.
|
||||
|
||||
## 10. Export und Backup
|
||||
|
||||
Wenn du Export- oder Backup-Funktionen nutzt, werden die von dir innerhalb der Anwendung gespeicherten Daten zusammengestellt und zum Download bereitgestellt. Diese Verarbeitung erfolgt ausschließlich zur Durchführung der von dir ausgelösten Funktion.
|
||||
|
||||
## 11. Rechtsgrundlagen
|
||||
|
||||
Soweit die Verarbeitung zur Bereitstellung und Durchführung der Funktionen von Stundenfuchs erforderlich ist, erfolgt sie auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO.
|
||||
|
||||
Soweit die Verarbeitung zur Gewährleistung der Sicherheit, Stabilität und Missbrauchsvermeidung erfolgt, beruht sie auf Art. 6 Abs. 1 lit. f DSGVO. Das berechtigte Interesse liegt in der sicheren, funktionsfähigen und wirtschaftlichen Bereitstellung des Dienstes.
|
||||
|
||||
Soweit du mich kontaktierst, erfolgt die Verarbeitung je nach Inhalt deiner Anfrage auf Art. 6 Abs. 1 lit. b DSGVO oder Art. 6 Abs. 1 lit. f DSGVO.
|
||||
|
||||
## 12. Empfänger von Daten
|
||||
|
||||
Personenbezogene Daten werden nur insoweit weitergegeben, wie dies für den Betrieb der Anwendung technisch erforderlich ist oder eine gesetzliche Verpflichtung besteht.
|
||||
|
||||
Hosting-, E-Mail- und sonstige Empfängerangaben müssen für den konkreten Produktivbetrieb ergänzt werden.
|
||||
|
||||
## 13. Speicherdauer
|
||||
|
||||
Personenbezogene Daten werden nur so lange gespeichert, wie dies für die jeweiligen Zwecke erforderlich ist oder gesetzliche Aufbewahrungspflichten bestehen.
|
||||
|
||||
Kontodaten und in der Anwendung gespeicherte Inhalte werden grundsätzlich so lange gespeichert, wie dein Benutzerkonto besteht, sofern keine gesetzlichen Pflichten entgegenstehen.
|
||||
|
||||
Kontaktanfragen und Tickets werden gespeichert, soweit dies zur Bearbeitung, Dokumentation und Missbrauchsabwehr erforderlich ist.
|
||||
|
||||
## 14. Deine Rechte
|
||||
|
||||
Du hast nach Maßgabe der gesetzlichen Vorschriften das Recht auf:
|
||||
|
||||
- Auskunft über die verarbeiteten personenbezogenen Daten
|
||||
- Berichtigung unrichtiger Daten
|
||||
- Löschung
|
||||
- Einschränkung der Verarbeitung
|
||||
- Datenübertragbarkeit
|
||||
- Widerspruch gegen Verarbeitungen auf Grundlage berechtigter Interessen
|
||||
|
||||
Wenn eine Verarbeitung auf einer Einwilligung beruht, kannst du diese jederzeit mit Wirkung für die Zukunft widerrufen.
|
||||
|
||||
## 15. Beschwerderecht
|
||||
|
||||
Du hast das Recht, dich bei einer Datenschutzaufsichtsbehörde zu beschweren.
|
||||
|
||||
## 16. Pflicht zur Bereitstellung von Daten
|
||||
|
||||
Soweit personenbezogene Daten für die Registrierung, Anmeldung oder Nutzung der Anwendung erforderlich sind, ist die Bereitstellung dieser Daten notwendig. Ohne diese Daten kann Stundenfuchs ganz oder teilweise nicht genutzt werden.
|
||||
|
||||
## 17. Keine automatisierte Entscheidungsfindung
|
||||
|
||||
Eine automatisierte Entscheidungsfindung einschließlich Profiling im Sinne von Art. 22 DSGVO findet nicht statt.
|
||||
|
||||
## 18. Keine Analyse- oder Drittinhalte
|
||||
|
||||
Es werden keine Analyse- oder Trackingdienste eingesetzt.
|
||||
|
||||
Es werden keine externen Schriftarten, kein externes Fehlertracking und keine eingebetteten Drittinhalte verwendet.
|
||||
|
||||
## 19. Stand
|
||||
|
||||
Stand: März 2026
|
||||
""",
|
||||
}
|
||||
|
||||
_ALLOWED_TAGS = [
|
||||
'a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'li', 'ol', 'p', 'pre', 'strong', 'ul'
|
||||
]
|
||||
_ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title', 'rel', 'target'],
|
||||
}
|
||||
_ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
|
||||
|
||||
|
||||
def default_site_content_markdown(key: str) -> str:
|
||||
return DEFAULT_SITE_CONTENT_MARKDOWN.get(key, '')
|
||||
|
||||
|
||||
def render_safe_markdown(markdown_text: str) -> str:
|
||||
raw_html = markdown_lib.markdown(
|
||||
markdown_text or '',
|
||||
extensions=['extra', 'sane_lists'],
|
||||
output_format='html5',
|
||||
)
|
||||
cleaned = bleach.clean(
|
||||
raw_html,
|
||||
tags=_ALLOWED_TAGS,
|
||||
attributes=_ALLOWED_ATTRIBUTES,
|
||||
protocols=_ALLOWED_PROTOCOLS,
|
||||
strip=True,
|
||||
)
|
||||
return bleach.linkify(cleaned)
|
||||
|
||||
|
||||
def normalize_markdown_input(value: str) -> str:
|
||||
return (value or '').strip()
|
||||
|
||||
|
||||
def ticket_status_label(status: str) -> str:
|
||||
return {
|
||||
'open': 'Offen',
|
||||
'closed': 'Geschlossen',
|
||||
}.get(status, status)
|
||||
|
||||
|
||||
def ticket_category_options() -> list[dict[str, str]]:
|
||||
return [
|
||||
{'value': 'problem', 'label': 'Problem'},
|
||||
{'value': 'feature', 'label': 'Featurerequest'},
|
||||
{'value': 'other', 'label': 'Sonstiges'},
|
||||
]
|
||||
|
||||
|
||||
def ticket_category_label(value: str) -> str:
|
||||
for item in ticket_category_options():
|
||||
if item['value'] == value:
|
||||
return item['label']
|
||||
return value
|
||||
@@ -0,0 +1,181 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
|
||||
def _table_columns(engine: Engine, table_name: str) -> set[str]:
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text(f"PRAGMA table_info({table_name})")).mappings().all()
|
||||
return {row["name"] for row in rows}
|
||||
|
||||
|
||||
def run_startup_migrations(engine: Engine) -> None:
|
||||
if engine.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
user_columns = _table_columns(engine, "users")
|
||||
|
||||
statements: list[str] = []
|
||||
if "preferred_home_view" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN preferred_home_view VARCHAR(16) NOT NULL DEFAULT 'week'")
|
||||
if "preferred_month_view_mode" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN preferred_month_view_mode VARCHAR(16) NOT NULL DEFAULT 'flat'")
|
||||
if "entry_mode" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN entry_mode VARCHAR(16) NOT NULL DEFAULT 'manual'")
|
||||
if "working_days_csv" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN working_days_csv VARCHAR(32) NOT NULL DEFAULT '0,1,2,3,4'")
|
||||
if "count_vacation_as_worktime" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN count_vacation_as_worktime BOOLEAN NOT NULL DEFAULT 0")
|
||||
if "count_holiday_as_worktime" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN count_holiday_as_worktime BOOLEAN NOT NULL DEFAULT 0")
|
||||
if "count_sick_as_worktime" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN count_sick_as_worktime BOOLEAN NOT NULL DEFAULT 0")
|
||||
if "automatic_break_rules_enabled" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN automatic_break_rules_enabled BOOLEAN NOT NULL DEFAULT 0")
|
||||
if "default_break_minutes" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN default_break_minutes INTEGER NOT NULL DEFAULT 0")
|
||||
if "overtime_start_date" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN overtime_start_date DATE")
|
||||
if "overtime_expiry_days" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN overtime_expiry_days INTEGER")
|
||||
if "expire_negative_overtime" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN expire_negative_overtime BOOLEAN NOT NULL DEFAULT 0")
|
||||
if "vacation_days_total" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN vacation_days_total INTEGER NOT NULL DEFAULT 0")
|
||||
if "vacation_show_in_header" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN vacation_show_in_header BOOLEAN NOT NULL DEFAULT 1")
|
||||
if "workhours_counter_enabled" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_enabled BOOLEAN NOT NULL DEFAULT 0")
|
||||
if "workhours_counter_show_in_header" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_show_in_header BOOLEAN NOT NULL DEFAULT 0")
|
||||
if "workhours_counter_start_date" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_start_date DATE")
|
||||
if "workhours_counter_end_date" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_end_date DATE")
|
||||
if "workhours_counter_manual_offset_minutes" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_manual_offset_minutes INTEGER NOT NULL DEFAULT 0")
|
||||
if "workhours_counter_target_minutes" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_target_minutes INTEGER")
|
||||
if "workhours_counter_target_email_enabled" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_target_email_enabled BOOLEAN NOT NULL DEFAULT 0")
|
||||
if "workhours_counter_warning_last_sent_on" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_warning_last_sent_on DATE")
|
||||
if "workhours_counter_warning_last_sent_key" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN workhours_counter_warning_last_sent_key VARCHAR(120)")
|
||||
if "federal_state" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN federal_state VARCHAR(8)")
|
||||
if "email_verified" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT 1")
|
||||
if "email_verification_token_hash" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN email_verification_token_hash VARCHAR(128)")
|
||||
if "email_verification_expires_at" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN email_verification_expires_at DATETIME")
|
||||
if "email_verification_sent_at" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN email_verification_sent_at DATETIME")
|
||||
if "mfa_method" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN mfa_method VARCHAR(16) NOT NULL DEFAULT 'none'")
|
||||
if "mfa_totp_secret_encrypted" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN mfa_totp_secret_encrypted TEXT")
|
||||
if "mfa_email_code_hash" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN mfa_email_code_hash VARCHAR(255)")
|
||||
if "mfa_email_code_expires_at" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN mfa_email_code_expires_at DATETIME")
|
||||
if "mfa_email_code_sent_at" not in user_columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN mfa_email_code_sent_at DATETIME")
|
||||
|
||||
email_config_columns = _table_columns(engine, "email_server_config")
|
||||
if "registration_admin_notify_enabled" not in email_config_columns:
|
||||
statements.append("ALTER TABLE email_server_config ADD COLUMN registration_admin_notify_enabled BOOLEAN NOT NULL DEFAULT 1")
|
||||
if "registration_admin_notify_admin_ids_csv" not in email_config_columns:
|
||||
statements.append("ALTER TABLE email_server_config ADD COLUMN registration_admin_notify_admin_ids_csv VARCHAR(1024)")
|
||||
|
||||
time_entry_columns = _table_columns(engine, "time_entries")
|
||||
if "break_rule_mode" not in time_entry_columns:
|
||||
statements.append("ALTER TABLE time_entries ADD COLUMN break_rule_mode VARCHAR(16) NOT NULL DEFAULT 'manual'")
|
||||
|
||||
if not statements:
|
||||
return
|
||||
|
||||
with engine.begin() as conn:
|
||||
for statement in statements:
|
||||
conn.execute(text(statement))
|
||||
conn.execute(text("UPDATE users SET entry_mode = 'auto_until_today' WHERE entry_mode = 'auto'"))
|
||||
conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS ix_users_email_verification_token_hash ON users (email_verification_token_hash)")
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS overtime_adjustments (
|
||||
id VARCHAR(36) PRIMARY KEY NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
minutes INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_user_overtime_adjustment_date UNIQUE (user_id, date)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_overtime_adjustments_user_id ON overtime_adjustments (user_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_overtime_adjustments_date ON overtime_adjustments (date)"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auto_entry_skips (
|
||||
id VARCHAR(36) PRIMARY KEY NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_user_auto_entry_skip_date UNIQUE (user_id, date)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_auto_entry_skips_user_id ON auto_entry_skips (user_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_auto_entry_skips_date ON auto_entry_skips (date)"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS site_content (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
key VARCHAR(64) NOT NULL UNIQUE,
|
||||
markdown_text TEXT NOT NULL DEFAULT '',
|
||||
updated_by_user_id VARCHAR(36),
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY(updated_by_user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_site_content_key ON site_content (key)"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
id VARCHAR(36) PRIMARY KEY NOT NULL,
|
||||
user_id VARCHAR(36),
|
||||
category VARCHAR(24) NOT NULL DEFAULT 'problem',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'open',
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
email VARCHAR(255) NOT NULL,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
admin_notes TEXT,
|
||||
source_ip_hash VARCHAR(128),
|
||||
source_user_agent VARCHAR(512),
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME,
|
||||
closed_at DATETIME,
|
||||
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_user_id ON support_tickets (user_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_email ON support_tickets (email)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_status ON support_tickets (status)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_source_ip_hash ON support_tickets (source_ip_hash)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_created_at ON support_tickets (created_at)"))
|
||||
@@ -0,0 +1,246 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.services.calculations import compute_net_minutes
|
||||
from app.services.targets import monday_of, target_map_for_weeks, week_starts_between
|
||||
from app.services.vacations import expand_vacation_dates
|
||||
from app.services.workdays import DEFAULT_WORKING_DAYS, is_workday
|
||||
|
||||
|
||||
def compute_effective_span_totals(
|
||||
*,
|
||||
entries: list,
|
||||
range_start: date,
|
||||
range_end: date,
|
||||
weekly_target_minutes: int,
|
||||
vacation_dates: set[date] | None,
|
||||
non_working_dates: set[date] | None,
|
||||
count_as_worktime_dates: set[date] | None,
|
||||
overtime_adjustment_minutes_by_date: dict[date, int] | None,
|
||||
overtime_start_date: date | None,
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> dict[str, int]:
|
||||
if range_end < range_start:
|
||||
return {
|
||||
"ist_minutes": 0,
|
||||
"soll_minutes": 0,
|
||||
"delta_minutes": 0,
|
||||
"eligible_workdays": 0,
|
||||
"vacation_workdays": 0,
|
||||
}
|
||||
|
||||
blocked_before = overtime_start_date
|
||||
vacation_dates = vacation_dates or set()
|
||||
non_working_dates = non_working_dates or set()
|
||||
count_as_worktime_dates = count_as_worktime_dates or set()
|
||||
overtime_adjustment_minutes_by_date = overtime_adjustment_minutes_by_date or {}
|
||||
relevant_weekdays = relevant_weekdays or set(DEFAULT_WORKING_DAYS)
|
||||
workdays_per_week = max(1, len(relevant_weekdays))
|
||||
|
||||
net_by_date: dict[date, int] = {}
|
||||
for entry in entries:
|
||||
if entry.date < range_start or entry.date > range_end:
|
||||
continue
|
||||
net_by_date[entry.date] = compute_net_minutes(
|
||||
entry.start_minutes,
|
||||
entry.end_minutes,
|
||||
entry.break_minutes,
|
||||
)
|
||||
|
||||
eligible_workdays = 0
|
||||
vacation_workdays = 0
|
||||
ist_minutes = 0
|
||||
overtime_adjustment_minutes = 0
|
||||
current = range_start
|
||||
while current <= range_end:
|
||||
overtime_adjustment_minutes += int(overtime_adjustment_minutes_by_date.get(current, 0))
|
||||
if blocked_before is None or current >= blocked_before:
|
||||
day_counts_as_worktime = current in count_as_worktime_dates and is_workday(current, relevant_weekdays)
|
||||
day_target_minutes = int(round(weekly_target_minutes / workdays_per_week)) if is_workday(current, relevant_weekdays) else 0
|
||||
if day_counts_as_worktime:
|
||||
ist_minutes += day_target_minutes
|
||||
elif current not in non_working_dates:
|
||||
ist_minutes += net_by_date.get(current, 0)
|
||||
if is_workday(current, relevant_weekdays):
|
||||
if current in vacation_dates and not day_counts_as_worktime:
|
||||
vacation_workdays += 1
|
||||
elif current in non_working_dates and not day_counts_as_worktime:
|
||||
pass
|
||||
else:
|
||||
eligible_workdays += 1
|
||||
current += timedelta(days=1)
|
||||
|
||||
soll_minutes = int(round((weekly_target_minutes / workdays_per_week) * eligible_workdays))
|
||||
delta_minutes = ist_minutes - soll_minutes + overtime_adjustment_minutes
|
||||
return {
|
||||
"ist_minutes": ist_minutes,
|
||||
"soll_minutes": soll_minutes,
|
||||
"delta_minutes": delta_minutes,
|
||||
"eligible_workdays": eligible_workdays,
|
||||
"vacation_workdays": vacation_workdays,
|
||||
"overtime_adjustment_minutes": overtime_adjustment_minutes,
|
||||
}
|
||||
|
||||
|
||||
def compute_effective_week_totals(
|
||||
*,
|
||||
entries: list,
|
||||
week_start: date,
|
||||
weekly_target_minutes: int,
|
||||
vacation_dates: set[date] | None,
|
||||
non_working_dates: set[date] | None,
|
||||
count_as_worktime_dates: set[date] | None,
|
||||
overtime_adjustment_minutes_by_date: dict[date, int] | None,
|
||||
overtime_start_date: date | None,
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> dict[str, int]:
|
||||
week_end = week_start + timedelta(days=6)
|
||||
totals = compute_effective_span_totals(
|
||||
entries=entries,
|
||||
range_start=week_start,
|
||||
range_end=week_end,
|
||||
weekly_target_minutes=weekly_target_minutes,
|
||||
vacation_dates=vacation_dates,
|
||||
non_working_dates=non_working_dates,
|
||||
count_as_worktime_dates=count_as_worktime_dates,
|
||||
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_by_date,
|
||||
overtime_start_date=overtime_start_date,
|
||||
relevant_weekdays=relevant_weekdays,
|
||||
)
|
||||
return {
|
||||
"weekly_ist": totals["ist_minutes"],
|
||||
"weekly_soll": totals["soll_minutes"],
|
||||
"weekly_delta": totals["delta_minutes"],
|
||||
}
|
||||
|
||||
|
||||
def compute_cumulative_overtime_minutes(
|
||||
*,
|
||||
entries: list,
|
||||
rules: list,
|
||||
weekly_target_fallback: int,
|
||||
vacation_periods: list,
|
||||
non_working_dates: set[date] | None,
|
||||
count_as_worktime_dates: set[date] | None,
|
||||
overtime_adjustment_minutes_by_date: dict[date, int] | None,
|
||||
selected_week_start: date,
|
||||
overtime_start_date: date | None,
|
||||
overtime_expiry_days: int | None,
|
||||
expire_negative_overtime: bool,
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> int:
|
||||
selected_week_end = selected_week_start + timedelta(days=6)
|
||||
return compute_cumulative_overtime_until_date(
|
||||
entries=entries,
|
||||
rules=rules,
|
||||
weekly_target_fallback=weekly_target_fallback,
|
||||
vacation_periods=vacation_periods,
|
||||
non_working_dates=non_working_dates,
|
||||
count_as_worktime_dates=count_as_worktime_dates,
|
||||
overtime_adjustment_minutes_by_date=overtime_adjustment_minutes_by_date,
|
||||
as_of_date=selected_week_end,
|
||||
overtime_start_date=overtime_start_date,
|
||||
overtime_expiry_days=overtime_expiry_days,
|
||||
expire_negative_overtime=expire_negative_overtime,
|
||||
relevant_weekdays=relevant_weekdays,
|
||||
)
|
||||
|
||||
|
||||
def compute_cumulative_overtime_until_date(
|
||||
*,
|
||||
entries: list,
|
||||
rules: list,
|
||||
weekly_target_fallback: int,
|
||||
vacation_periods: list,
|
||||
non_working_dates: set[date] | None,
|
||||
count_as_worktime_dates: set[date] | None,
|
||||
overtime_adjustment_minutes_by_date: dict[date, int] | None,
|
||||
as_of_date: date,
|
||||
overtime_start_date: date | None,
|
||||
overtime_expiry_days: int | None,
|
||||
expire_negative_overtime: bool,
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> int:
|
||||
relevant_weekdays = relevant_weekdays or set(DEFAULT_WORKING_DAYS)
|
||||
workdays_per_week = max(1, len(relevant_weekdays))
|
||||
overtime_adjustment_minutes_by_date = overtime_adjustment_minutes_by_date or {}
|
||||
|
||||
earliest_entry_date = min((entry.date for entry in entries), default=None)
|
||||
earliest_adjustment_date = min(overtime_adjustment_minutes_by_date.keys(), default=None)
|
||||
|
||||
range_start_candidates = [candidate for candidate in [earliest_entry_date, earliest_adjustment_date] if candidate is not None]
|
||||
if not range_start_candidates:
|
||||
return 0
|
||||
|
||||
range_start = min(range_start_candidates)
|
||||
|
||||
if range_start > as_of_date:
|
||||
return 0
|
||||
|
||||
first_week_start = monday_of(range_start)
|
||||
relevant_weeks = week_starts_between(first_week_start, monday_of(as_of_date))
|
||||
base_target_map = target_map_for_weeks(rules, relevant_weeks, weekly_target_fallback)
|
||||
|
||||
vacation_dates = expand_vacation_dates(
|
||||
vacation_periods,
|
||||
range_start,
|
||||
as_of_date,
|
||||
relevant_weekdays=relevant_weekdays,
|
||||
)
|
||||
non_working_dates = non_working_dates or set()
|
||||
count_as_worktime_dates = count_as_worktime_dates or set()
|
||||
|
||||
net_by_date: dict[date, int] = {}
|
||||
for entry in entries:
|
||||
if entry.date < range_start or entry.date > as_of_date:
|
||||
continue
|
||||
net_by_date[entry.date] = compute_net_minutes(
|
||||
entry.start_minutes,
|
||||
entry.end_minutes,
|
||||
entry.break_minutes,
|
||||
)
|
||||
|
||||
cutoff_date: date | None = None
|
||||
if overtime_expiry_days is not None and overtime_expiry_days > 0:
|
||||
cutoff_date = as_of_date - timedelta(days=overtime_expiry_days)
|
||||
|
||||
total = 0.0
|
||||
current = range_start
|
||||
while current <= as_of_date:
|
||||
week_start = monday_of(current)
|
||||
weekly_target = base_target_map.get(week_start, weekly_target_fallback)
|
||||
day_adjustment = float(overtime_adjustment_minutes_by_date.get(current, 0))
|
||||
|
||||
regular_delta_allowed = overtime_start_date is None or current >= overtime_start_date
|
||||
|
||||
day_counts_as_worktime = current in count_as_worktime_dates and current.weekday() in relevant_weekdays
|
||||
|
||||
if regular_delta_allowed and current.weekday() in relevant_weekdays and (current not in vacation_dates or day_counts_as_worktime):
|
||||
if current in non_working_dates and not day_counts_as_worktime:
|
||||
day_target = 0.0
|
||||
else:
|
||||
day_target = weekly_target / workdays_per_week
|
||||
else:
|
||||
day_target = 0.0
|
||||
|
||||
if regular_delta_allowed:
|
||||
if day_counts_as_worktime:
|
||||
day_net = day_target
|
||||
else:
|
||||
day_net = 0.0 if current in non_working_dates else float(net_by_date.get(current, 0))
|
||||
else:
|
||||
day_net = 0.0
|
||||
delta = day_net - day_target + day_adjustment
|
||||
|
||||
expired = cutoff_date is not None and current < cutoff_date
|
||||
if expired:
|
||||
if delta > 0:
|
||||
current += timedelta(days=1)
|
||||
continue
|
||||
if delta < 0 and expire_negative_overtime:
|
||||
current += timedelta(days=1)
|
||||
continue
|
||||
|
||||
total += delta
|
||||
current += timedelta(days=1)
|
||||
|
||||
return int(round(total))
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import holidays
|
||||
|
||||
|
||||
GERMAN_STATE_OPTIONS: list[dict[str, str]] = [
|
||||
{"code": "BW", "label": "Baden-Württemberg"},
|
||||
{"code": "BY", "label": "Bayern"},
|
||||
{"code": "BE", "label": "Berlin"},
|
||||
{"code": "BB", "label": "Brandenburg"},
|
||||
{"code": "HB", "label": "Bremen"},
|
||||
{"code": "HH", "label": "Hamburg"},
|
||||
{"code": "HE", "label": "Hessen"},
|
||||
{"code": "MV", "label": "Mecklenburg-Vorpommern"},
|
||||
{"code": "NI", "label": "Niedersachsen"},
|
||||
{"code": "NW", "label": "Nordrhein-Westfalen"},
|
||||
{"code": "RP", "label": "Rheinland-Pfalz"},
|
||||
{"code": "SL", "label": "Saarland"},
|
||||
{"code": "SN", "label": "Sachsen"},
|
||||
{"code": "ST", "label": "Sachsen-Anhalt"},
|
||||
{"code": "SH", "label": "Schleswig-Holstein"},
|
||||
{"code": "TH", "label": "Thüringen"},
|
||||
]
|
||||
GERMAN_STATE_CODES = {item["code"] for item in GERMAN_STATE_OPTIONS}
|
||||
|
||||
|
||||
def normalize_german_state_code(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
if normalized not in GERMAN_STATE_CODES:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def list_public_holiday_dates(
|
||||
*,
|
||||
federal_state: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
) -> set[date]:
|
||||
if to_date < from_date:
|
||||
return set()
|
||||
years = list(range(from_date.year, to_date.year + 1))
|
||||
holiday_map = holidays.country_holidays("DE", subdiv=federal_state, years=years)
|
||||
result: set[date] = set()
|
||||
for holiday_date in holiday_map.keys():
|
||||
if from_date <= holiday_date <= to_date:
|
||||
result.add(holiday_date)
|
||||
return result
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from base64 import urlsafe_b64encode
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
import pyotp
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _derive_fernet_key(source: str) -> bytes:
|
||||
digest = hashlib.sha256(source.encode("utf-8")).digest()
|
||||
return urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def build_fernet(secret_source: str) -> Fernet:
|
||||
return Fernet(_derive_fernet_key(secret_source))
|
||||
|
||||
|
||||
def encrypt_secret(fernet: Fernet, value: str) -> str:
|
||||
return fernet.encrypt(value.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_secret(fernet: Fernet, value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return fernet.decrypt(value.encode("utf-8")).decode("utf-8")
|
||||
except InvalidToken:
|
||||
return None
|
||||
|
||||
|
||||
def generate_numeric_code(length: int = 6) -> str:
|
||||
if length <= 0:
|
||||
raise ValueError("length must be positive")
|
||||
lower = 10 ** (length - 1)
|
||||
upper = (10**length) - 1
|
||||
return str(secrets.randbelow(upper - lower + 1) + lower)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_reset_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def normalize_otp_code(code: str) -> str:
|
||||
return "".join(ch for ch in code.strip() if ch.isdigit())
|
||||
|
||||
|
||||
def generate_totp_secret() -> str:
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def build_totp_uri(*, secret: str, account_name: str, issuer: str = "Stundenfuchs") -> str:
|
||||
return pyotp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer)
|
||||
|
||||
|
||||
def verify_totp_code(*, secret: str, code: str) -> bool:
|
||||
normalized = normalize_otp_code(code)
|
||||
if len(normalized) != 6:
|
||||
return False
|
||||
return bool(pyotp.TOTP(secret).verify(normalized, valid_window=1))
|
||||
@@ -0,0 +1,148 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import User, WeeklyTargetRule
|
||||
|
||||
DEFAULT_REFERENCE_WEEK_START = date(1970, 1, 5) # Montag
|
||||
|
||||
|
||||
def monday_of(day: date) -> date:
|
||||
return day - timedelta(days=day.weekday())
|
||||
|
||||
|
||||
def week_starts_between(start_week_start: date, end_week_start: date) -> list[date]:
|
||||
weeks: list[date] = []
|
||||
current = start_week_start
|
||||
while current <= end_week_start:
|
||||
weeks.append(current)
|
||||
current += timedelta(days=7)
|
||||
return weeks
|
||||
|
||||
|
||||
def list_rules_for_user(db: Session, user_id: str) -> list[WeeklyTargetRule]:
|
||||
stmt = (
|
||||
select(WeeklyTargetRule)
|
||||
.where(WeeklyTargetRule.user_id == user_id)
|
||||
.order_by(WeeklyTargetRule.effective_from.asc())
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def target_for_week(
|
||||
rules: list[WeeklyTargetRule],
|
||||
week_start: date,
|
||||
fallback_minutes: int,
|
||||
) -> int:
|
||||
target = fallback_minutes
|
||||
for rule in rules:
|
||||
if rule.effective_from <= week_start:
|
||||
target = rule.weekly_target_minutes
|
||||
else:
|
||||
break
|
||||
return target
|
||||
|
||||
|
||||
def target_map_for_weeks(
|
||||
rules: list[WeeklyTargetRule],
|
||||
week_starts: list[date],
|
||||
fallback_minutes: int,
|
||||
) -> dict[date, int]:
|
||||
result: dict[date, int] = {}
|
||||
for week_start in week_starts:
|
||||
result[week_start] = target_for_week(rules, week_start, fallback_minutes)
|
||||
return result
|
||||
|
||||
|
||||
def upsert_rule(db: Session, user_id: str, effective_from: date, weekly_target_minutes: int) -> None:
|
||||
stmt = select(WeeklyTargetRule).where(
|
||||
WeeklyTargetRule.user_id == user_id,
|
||||
WeeklyTargetRule.effective_from == effective_from,
|
||||
)
|
||||
rule = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if rule:
|
||||
rule.weekly_target_minutes = weekly_target_minutes
|
||||
return
|
||||
|
||||
db.add(
|
||||
WeeklyTargetRule(
|
||||
user_id=user_id,
|
||||
effective_from=effective_from,
|
||||
weekly_target_minutes=weekly_target_minutes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ensure_user_has_default_target_rule(db: Session, user: User) -> None:
|
||||
stmt = select(WeeklyTargetRule.id).where(WeeklyTargetRule.user_id == user.id).limit(1)
|
||||
existing = db.execute(stmt).scalar_one_or_none()
|
||||
if existing:
|
||||
return
|
||||
|
||||
db.add(
|
||||
WeeklyTargetRule(
|
||||
user_id=user.id,
|
||||
effective_from=DEFAULT_REFERENCE_WEEK_START,
|
||||
weekly_target_minutes=user.weekly_target_minutes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ensure_all_users_have_default_target_rules(db: Session) -> None:
|
||||
users = db.execute(select(User)).scalars().all()
|
||||
changed = False
|
||||
for user in users:
|
||||
before_count = db.execute(
|
||||
select(WeeklyTargetRule.id).where(WeeklyTargetRule.user_id == user.id).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if before_count:
|
||||
continue
|
||||
ensure_user_has_default_target_rule(db, user)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
db.commit()
|
||||
|
||||
|
||||
def apply_weekly_target_change(
|
||||
db: Session,
|
||||
*,
|
||||
user: User,
|
||||
selected_week_start: date,
|
||||
new_target_minutes: int,
|
||||
scope: str,
|
||||
) -> None:
|
||||
rules = list_rules_for_user(db, user.id)
|
||||
fallback = user.weekly_target_minutes
|
||||
|
||||
if scope == "all_weeks":
|
||||
db.execute(delete(WeeklyTargetRule).where(WeeklyTargetRule.user_id == user.id))
|
||||
db.add(
|
||||
WeeklyTargetRule(
|
||||
user_id=user.id,
|
||||
effective_from=DEFAULT_REFERENCE_WEEK_START,
|
||||
weekly_target_minutes=new_target_minutes,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if scope == "from_current_week":
|
||||
db.execute(
|
||||
delete(WeeklyTargetRule).where(
|
||||
WeeklyTargetRule.user_id == user.id,
|
||||
WeeklyTargetRule.effective_from >= selected_week_start,
|
||||
)
|
||||
)
|
||||
upsert_rule(db, user.id, selected_week_start, new_target_minutes)
|
||||
return
|
||||
|
||||
if scope == "current_week":
|
||||
next_week_start = selected_week_start + timedelta(days=7)
|
||||
target_next_week_before = target_for_week(rules, next_week_start, fallback)
|
||||
upsert_rule(db, user.id, selected_week_start, new_target_minutes)
|
||||
upsert_rule(db, user.id, next_week_start, target_next_week_before)
|
||||
return
|
||||
|
||||
raise ValueError("Ungueltiger Scope")
|
||||
@@ -0,0 +1,162 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import VacationPeriod
|
||||
from app.services.workdays import DEFAULT_WORKING_DAYS
|
||||
|
||||
|
||||
def daterange(start: date, end: date):
|
||||
current = start
|
||||
while current <= end:
|
||||
yield current
|
||||
current += timedelta(days=1)
|
||||
|
||||
|
||||
def list_vacations_for_user(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
) -> list[VacationPeriod]:
|
||||
stmt = (
|
||||
select(VacationPeriod)
|
||||
.where(
|
||||
VacationPeriod.user_id == user_id,
|
||||
VacationPeriod.end_date >= from_date,
|
||||
VacationPeriod.start_date <= to_date,
|
||||
)
|
||||
.order_by(VacationPeriod.start_date.asc())
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def expand_vacation_dates(
|
||||
periods: list[VacationPeriod],
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> set[date]:
|
||||
dates: set[date] = set()
|
||||
|
||||
for period in periods:
|
||||
start = max(period.start_date, from_date)
|
||||
end = min(period.end_date, to_date)
|
||||
if end < start:
|
||||
continue
|
||||
|
||||
for day in daterange(start, end):
|
||||
if not period.include_weekends:
|
||||
if relevant_weekdays is None:
|
||||
if day.weekday() >= 5:
|
||||
continue
|
||||
elif day.weekday() not in relevant_weekdays:
|
||||
continue
|
||||
dates.add(day)
|
||||
|
||||
return dates
|
||||
|
||||
|
||||
def collapse_dates_to_ranges(days: set[date]) -> list[tuple[date, date]]:
|
||||
if not days:
|
||||
return []
|
||||
|
||||
ordered = sorted(days)
|
||||
ranges: list[tuple[date, date]] = []
|
||||
start = ordered[0]
|
||||
end = ordered[0]
|
||||
|
||||
for current in ordered[1:]:
|
||||
if current == end + timedelta(days=1):
|
||||
end = current
|
||||
continue
|
||||
ranges.append((start, end))
|
||||
start = current
|
||||
end = current
|
||||
|
||||
ranges.append((start, end))
|
||||
return ranges
|
||||
|
||||
|
||||
def vacation_workdays_in_week(
|
||||
vacation_dates: set[date],
|
||||
week_start: date,
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> int:
|
||||
relevant_weekdays = relevant_weekdays or set(DEFAULT_WORKING_DAYS)
|
||||
count = 0
|
||||
for index in range(7):
|
||||
day = week_start + timedelta(days=index)
|
||||
if day in vacation_dates and day.weekday() in relevant_weekdays:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def effective_week_target(
|
||||
base_target_minutes: int,
|
||||
vacation_workdays: int,
|
||||
*,
|
||||
workdays_per_week: int = 5,
|
||||
) -> int:
|
||||
if vacation_workdays <= 0:
|
||||
return base_target_minutes
|
||||
|
||||
workdays_per_week = max(1, workdays_per_week)
|
||||
vacation_workdays = min(vacation_workdays, workdays_per_week)
|
||||
day_target = base_target_minutes / workdays_per_week
|
||||
reduced = int(round(base_target_minutes - (day_target * vacation_workdays)))
|
||||
return max(0, reduced)
|
||||
|
||||
|
||||
def apply_vacation_to_week_targets(
|
||||
base_target_map: dict[date, int],
|
||||
vacation_dates: set[date],
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> dict[date, int]:
|
||||
relevant_weekdays = relevant_weekdays or set(DEFAULT_WORKING_DAYS)
|
||||
workdays_per_week = max(1, len(relevant_weekdays))
|
||||
effective_map: dict[date, int] = {}
|
||||
for week_start, base_target in base_target_map.items():
|
||||
vacation_days = vacation_workdays_in_week(vacation_dates, week_start, relevant_weekdays)
|
||||
effective_map[week_start] = effective_week_target(
|
||||
base_target,
|
||||
vacation_days,
|
||||
workdays_per_week=workdays_per_week,
|
||||
)
|
||||
return effective_map
|
||||
|
||||
|
||||
def vacation_dates_for_weeks(
|
||||
periods: list[VacationPeriod],
|
||||
week_starts: list[date],
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> set[date]:
|
||||
if not week_starts:
|
||||
return set()
|
||||
|
||||
from_date = min(week_starts)
|
||||
to_date = max(week_starts) + timedelta(days=6)
|
||||
return expand_vacation_dates(periods, from_date, to_date, relevant_weekdays=relevant_weekdays)
|
||||
|
||||
|
||||
def week_target_map_with_vacations(
|
||||
base_target_map: dict[date, int],
|
||||
periods: list[VacationPeriod],
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> dict[date, int]:
|
||||
vacation_dates = vacation_dates_for_weeks(periods, list(base_target_map.keys()), relevant_weekdays=relevant_weekdays)
|
||||
return apply_vacation_to_week_targets(base_target_map, vacation_dates, relevant_weekdays)
|
||||
|
||||
|
||||
def vacations_by_week(
|
||||
periods: list[VacationPeriod],
|
||||
week_starts: list[date],
|
||||
relevant_weekdays: set[int] | None = None,
|
||||
) -> dict[date, int]:
|
||||
relevant_weekdays = relevant_weekdays or set(DEFAULT_WORKING_DAYS)
|
||||
vacation_dates = vacation_dates_for_weeks(periods, week_starts, relevant_weekdays=relevant_weekdays)
|
||||
result: dict[date, int] = {}
|
||||
for week_start in week_starts:
|
||||
result[week_start] = vacation_workdays_in_week(vacation_dates, week_start, relevant_weekdays)
|
||||
return result
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import date
|
||||
|
||||
|
||||
DEFAULT_WORKING_DAYS = (0, 1, 2, 3, 4)
|
||||
|
||||
|
||||
def normalize_working_days(days: list[int] | set[int] | tuple[int, ...]) -> list[int]:
|
||||
normalized = sorted({int(day) for day in days if 0 <= int(day) <= 6})
|
||||
if not normalized:
|
||||
return list(DEFAULT_WORKING_DAYS)
|
||||
return normalized
|
||||
|
||||
|
||||
def serialize_working_days(days: list[int] | set[int] | tuple[int, ...]) -> str:
|
||||
return ",".join(str(day) for day in normalize_working_days(days))
|
||||
|
||||
|
||||
def parse_working_days_csv(value: str | None) -> set[int]:
|
||||
if not value:
|
||||
return set(DEFAULT_WORKING_DAYS)
|
||||
|
||||
parsed: list[int] = []
|
||||
for part in value.split(","):
|
||||
item = part.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
parsed.append(int(item))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
normalized = normalize_working_days(parsed)
|
||||
return set(normalized)
|
||||
|
||||
|
||||
def is_workday(day: date, relevant_weekdays: set[int]) -> bool:
|
||||
return day.weekday() in relevant_weekdays
|
||||
@@ -0,0 +1,5 @@
|
||||
@import url("./tokens.css");
|
||||
@import url("./base.css");
|
||||
@import url("./layout.css");
|
||||
@import url("./components.css");
|
||||
@import url("./utilities.css");
|
||||
@@ -0,0 +1,54 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: var(--border-width-1) solid var(--color-border);
|
||||
margin: var(--space-5) 0;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: var(--border-width-1) solid var(--color-warning);
|
||||
outline-offset: var(--space-1);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--gutter-desktop);
|
||||
width: min(var(--container-max), 100%);
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--stack-2) 0 var(--space-7);
|
||||
}
|
||||
|
||||
.dashboard-page,
|
||||
.month-page,
|
||||
.settings-page,
|
||||
.register-page,
|
||||
.legal-page,
|
||||
.contact-page {
|
||||
padding-top: var(--space-0);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
background: var(--color-bg);
|
||||
border-bottom: var(--border-width-1) solid var(--color-border);
|
||||
position: sticky;
|
||||
top: var(--space-0);
|
||||
z-index: var(--z-header);
|
||||
}
|
||||
|
||||
.site-header .container {
|
||||
padding-top: var(--header-pad-y);
|
||||
padding-bottom: var(--header-pad-y);
|
||||
}
|
||||
|
||||
.app-topbar-inner {
|
||||
align-items: center;
|
||||
column-gap: var(--space-4);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
min-height: calc(var(--control-h) + var(--space-1));
|
||||
}
|
||||
|
||||
.site-header.is-auth-header .container {
|
||||
padding-top: var(--header-pad-y-auth);
|
||||
padding-bottom: var(--header-pad-y-auth);
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-guest {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-guest .app-auth-nav {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.site-header.is-auth-header .app-topbar-inner.is-guest {
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-user .app-user-nav {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: var(--space-4);
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: var(--border-width-1) solid var(--color-border);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.site-footer-inner {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--page-content-inset) var(--space-5);
|
||||
}
|
||||
|
||||
.admin-version-badge {
|
||||
background: color-mix(in srgb, var(--color-surface-2) 94%, transparent);
|
||||
border: var(--border-width-1) solid var(--color-border);
|
||||
bottom: var(--space-3);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
position: fixed;
|
||||
right: var(--space-3);
|
||||
z-index: var(--z-header);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
padding-inline: var(--page-content-inset);
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.page-header__subtitle {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.app-page-actions-wrap {
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.app-page-actions-wrap:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
margin-bottom: var(--space-2);
|
||||
padding-inline: var(--page-content-inset);
|
||||
}
|
||||
|
||||
@media (max-width: 51.25em) {
|
||||
.container {
|
||||
padding: 0 var(--gutter-tablet);
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-user {
|
||||
gap: var(--header-pad-y);
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-user .app-brand-wrap {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-user .app-user-nav {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
justify-self: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-user .app-user-nav .app-main-nav {
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-user .app-user-nav .app-icon-nav {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.app-topbar-inner.is-guest {
|
||||
column-gap: var(--space-2);
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.admin-version-badge {
|
||||
bottom: var(--space-2);
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.site-footer-inner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 32.5em) {
|
||||
.container {
|
||||
padding: 0 var(--gutter-mobile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
:root {
|
||||
--color-bg: #2c2d2f;
|
||||
--color-surface: #27282a;
|
||||
--color-surface-2: #1f2022;
|
||||
--color-surface-3: #232426;
|
||||
--color-surface-4: #2a2b2e;
|
||||
--color-border: #34363a;
|
||||
--color-border-soft: #2f3135;
|
||||
--color-text: #f5f5f5;
|
||||
--color-text-muted: #8f9298;
|
||||
--color-link: #f5f5f5;
|
||||
--color-primary: #3a3c40;
|
||||
--color-primary-hover: #46494e;
|
||||
--color-button-primary: #355d3a;
|
||||
--color-button-primary-hover: #3f6f45;
|
||||
--color-button-primary-border: #4d7f53;
|
||||
--color-danger: #bc5252;
|
||||
--color-danger-strong: #ff3b3b;
|
||||
--color-success: #9ed7a7;
|
||||
--color-success-strong: #2cd600;
|
||||
--color-success-bg: #243427;
|
||||
--color-warning: #c98f13;
|
||||
--color-warning-bg: #6d5500;
|
||||
--color-chip-bg: #202124;
|
||||
--color-weekend: #22252b;
|
||||
--color-day-today: #1a3f4a;
|
||||
--color-accent: #9e7700;
|
||||
--color-badge-bg: #f3f3f3;
|
||||
--color-badge-text: #222326;
|
||||
--color-workhours: #7f53d9;
|
||||
--color-header-badge-bg: #1d1d1f;
|
||||
--color-header-badge-label: #f5f5f5;
|
||||
--color-header-badge-text: #f5f5f5;
|
||||
|
||||
--font-family-base: "Atkinson Hyperlegible", "Segoe UI", sans-serif;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-md: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.5rem;
|
||||
--font-size-2xl: 2rem;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--line-height-tight: 1.2;
|
||||
|
||||
--space-0: 0;
|
||||
--space-1: 0.125rem;
|
||||
--space-2: 0.25rem;
|
||||
--space-3: 0.5rem;
|
||||
--space-4: 0.75rem;
|
||||
--space-5: 1rem;
|
||||
--space-6: 1.25rem;
|
||||
--space-7: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--stack-1: 0.5rem;
|
||||
--stack-2: 0.75rem;
|
||||
--stack-3: 1rem;
|
||||
|
||||
--radius-none: 0;
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
|
||||
--border-width-1: 1px;
|
||||
|
||||
--container-max: 100%;
|
||||
--gutter-desktop: 1rem;
|
||||
--gutter-tablet: 1rem;
|
||||
--gutter-mobile: 0.875rem;
|
||||
--page-content-inset: 1rem;
|
||||
--surface-pad: 1rem;
|
||||
--surface-pad-compact: 0.875rem;
|
||||
--control-h-desktop: 2rem;
|
||||
--control-h-mobile: 2rem;
|
||||
--control-h: var(--control-h-desktop);
|
||||
--list-icon-size: 2rem;
|
||||
--icon-size-sm: var(--list-icon-size);
|
||||
--icon-size: var(--list-icon-size);
|
||||
--header-icon-size: var(--list-icon-size);
|
||||
--logo-size: 1.375rem;
|
||||
--header-pad-y: 0.625rem;
|
||||
--header-pad-y-auth: 0.625rem;
|
||||
--badge-height: 2rem;
|
||||
--badge-pad-top: 0.2rem;
|
||||
--badge-pad-bottom: 0.05rem;
|
||||
--input-height: 2.5rem;
|
||||
--chip-h: 2rem;
|
||||
--row-height-compact: 2.625rem;
|
||||
--kpi-height: 2.75rem;
|
||||
--period-height: 3.25rem;
|
||||
--day-col: 16rem;
|
||||
--chip-col: 8.5rem;
|
||||
--actions-col: 7.5rem;
|
||||
|
||||
--z-header: 20;
|
||||
--z-modal: 40;
|
||||
|
||||
--bp-md: 51.25em;
|
||||
--bp-sm: 32.5em;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.is-hidden,
|
||||
.u-hidden,
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.u-stack-sm {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.u-stack-md {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// legacy script deprecated. Use /static/js/app.js only.
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.4688 23.5227V8.15909H17.5256V23.5227H14.4688ZM8.32102 17.3636V14.3068H23.6847V17.3636H8.32102Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.5001 15.4999C22.0533 16.053 22.0533 16.9514 21.5001 17.5046L14.4196 24.5851C13.8664 25.1383 12.968 25.1383 12.4149 24.5851C11.8617 24.032 11.8617 23.1336 12.4149 22.5804L18.4953 16.5L12.4193 10.4196C11.8661 9.86639 11.8661 8.96805 12.4193 8.41488C12.9725 7.86171 13.8708 7.86171 14.424 8.41488L21.5046 15.4954L21.5001 15.4999Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.2814 8.47073C18.6847 7.91612 18.5606 7.14042 18.006 6.73707C17.4514 6.33371 16.6757 6.45782 16.2723 7.01244L12.1612 12.6633L10.7068 11.2089C10.2219 10.7241 9.43462 10.7241 8.94982 11.2089C8.46501 11.6937 8.46501 12.4811 8.94982 12.9659L11.432 15.4481C11.688 15.704 12.0448 15.8359 12.4055 15.8088C12.7662 15.7816 13.0998 15.5955 13.3131 15.3007L18.2775 8.47461L18.2814 8.47073ZM23.2458 13.7454C23.6492 13.1908 23.525 12.4151 22.9704 12.0118C22.4158 11.6084 21.6401 11.7325 21.2368 12.2871L14.6434 21.3511L11.9479 18.6556C11.4631 18.1707 10.6757 18.1707 10.1909 18.6556C9.70612 19.1404 9.70612 19.9277 10.1909 20.4125L13.9142 24.1358C14.1702 24.3918 14.527 24.5236 14.8877 24.4965C15.2484 24.4694 15.582 24.2832 15.7953 23.9884L23.2419 13.7493L23.2458 13.7454Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 892 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 9.42857C8 8.08906 9.08906 7 10.4286 7H22.5714C23.9109 7 25 8.08906 25 9.42857V21.5714C25 22.9109 23.9109 24 22.5714 24H10.4286C9.08906 24 8 22.9109 8 21.5714V9.42857ZM10.4286 11.8571V21.5714H15.2857V11.8571H10.4286ZM22.5714 11.8571H17.7143V21.5714H22.5714V11.8571Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.5 7C11.8051 7 8 10.8051 8 15.5C8 20.1949 11.8051 24 16.5 24C18.7844 24 20.8596 23.0969 22.3869 21.6293C22.6293 21.3969 22.699 21.035 22.5629 20.7295C22.4268 20.424 22.108 20.2348 21.7727 20.2613C21.61 20.2746 21.4473 20.2812 21.2812 20.2812C17.9078 20.2812 15.1719 17.5453 15.1719 14.1719C15.1719 11.7779 16.5498 9.70273 18.5619 8.7C18.8641 8.55059 19.0367 8.2252 18.9969 7.88984C18.957 7.55449 18.7113 7.28223 18.3826 7.20918C17.775 7.07305 17.1441 7 16.5 7Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 592 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.6602 7.74659C12.8102 7.3 13.2261 7 13.6966 7H17.5795C18.05 7 18.4659 7.3 18.6159 7.74659L18.9091 8.63636H22.1818C22.7852 8.63636 23.2727 9.12386 23.2727 9.72727C23.2727 10.3307 22.7852 10.8182 22.1818 10.8182H9.09091C8.4875 10.8182 8 10.3307 8 9.72727C8 9.12386 8.4875 8.63636 9.09091 8.63636H12.3636L12.6602 7.74659ZM9.09091 12.4545H22.1818V22.8182C22.1818 24.0216 21.2034 25 20 25H11.2727C10.0693 25 9.09091 24.0216 9.09091 22.8182V12.4545ZM12.0909 14.6364C11.6375 14.6364 11.2727 15.0011 11.2727 15.4545V22C11.2727 22.4534 11.6375 22.8182 12.0909 22.8182C12.5443 22.8182 12.9091 22.4534 12.9091 22V15.4545C12.9091 15.0011 12.5443 14.6364 12.0909 14.6364ZM15.6364 14.6364C15.183 14.6364 14.8182 15.0011 14.8182 15.4545V22C14.8182 22.4534 15.183 22.8182 15.6364 22.8182C16.0898 22.8182 16.4545 22.4534 16.4545 22V15.4545C16.4545 15.0011 16.0898 14.6364 15.6364 14.6364ZM19.1818 14.6364C18.7284 14.6364 18.3636 15.0011 18.3636 15.4545V22C18.3636 22.4534 18.7284 22.8182 19.1818 22.8182C19.6352 22.8182 20 22.4534 20 22V15.4545C20 15.0011 19.6352 14.6364 19.1818 14.6364Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.2523 7.08345C23.4743 6.30552 22.2169 6.30552 21.4389 7.08345L20.5722 7.95019L24.0498 11.4278L24.9165 10.5611C25.6945 9.78314 25.6945 8.52566 24.9165 7.74772L24.2523 7.08345ZM13.624 14.8983C13.4073 15.115 13.2404 15.3814 13.1445 15.6763L12.093 18.8307C11.99 19.1361 12.0717 19.4736 12.2991 19.7045C12.5264 19.9354 12.8639 20.0135 13.1729 19.9105L16.3273 18.8591C16.6186 18.7632 16.885 18.5962 17.1052 18.3795L22.8456 12.632L19.368 9.1544L13.624 14.8983ZM10.9101 8.58604C9.02746 8.58604 7.5 10.1135 7.5 11.9962V21.0899C7.5 22.9725 9.02746 24.5 10.9101 24.5H20.0038C21.8865 24.5 23.414 22.9725 23.414 21.0899V17.6797C23.414 17.051 22.906 16.543 22.2772 16.543C21.6485 16.543 21.1405 17.051 21.1405 17.6797V21.0899C21.1405 21.7186 20.6326 22.2266 20.0038 22.2266H10.9101C10.2814 22.2266 9.77342 21.7186 9.77342 21.0899V11.9962C9.77342 11.3674 10.2814 10.8595 10.9101 10.8595H14.3203C14.949 10.8595 15.457 10.3515 15.457 9.72275C15.457 9.09401 14.949 8.58604 14.3203 8.58604H10.9101Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.6868 7.79781V10.1883H17.5619C14.9225 10.1883 12.781 12.3298 12.781 14.9693C12.781 18.0703 15.53 19.4448 16.121 19.7038C16.1941 19.737 16.2737 19.7502 16.3567 19.7502H16.4397C16.7651 19.7502 17.0307 19.4846 17.0307 19.1593C17.0307 18.8837 16.8348 18.6446 16.6058 18.4853C16.3103 18.2794 15.9683 17.881 15.9683 17.1406C15.9683 15.6466 17.1801 14.4347 18.6742 14.4347H19.6868V16.8252C19.6868 17.1473 19.8794 17.4394 20.1782 17.5623C20.477 17.6851 20.819 17.6187 21.0481 17.3896L25.5634 12.8743C25.8755 12.5622 25.8755 12.0575 25.5634 11.7488L21.0481 7.23339C20.819 7.0043 20.477 6.9379 20.1782 7.06075C19.8794 7.18359 19.6868 7.47576 19.6868 7.79781ZM10.6561 10.1883C9.1886 10.1883 8 11.3769 8 12.8444V21.3439C8 22.8114 9.1886 24 10.6561 24H19.1556C20.6231 24 21.8117 22.8114 21.8117 21.3439V20.2815C21.8117 19.6938 21.3369 19.219 20.7493 19.219C20.1616 19.219 19.6868 19.6938 19.6868 20.2815V21.3439C19.6868 21.6361 19.4478 21.8751 19.1556 21.8751H10.6561C10.3639 21.8751 10.1249 21.6361 10.1249 21.3439V12.8444C10.1249 12.5522 10.3639 12.3132 10.6561 12.3132H11.1873C11.775 12.3132 12.2498 11.8384 12.2498 11.2507C12.2498 10.6631 11.775 10.1883 11.1873 10.1883H10.6561Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 628 B |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2.75c5.108 0 9.25 4.142 9.25 9.25s-4.142 9.25-9.25 9.25S2.75 17.108 2.75 12 6.892 2.75 12 2.75Z"
|
||||
stroke="white"
|
||||
stroke-width="1.8" />
|
||||
<path d="M9.95 9.3a2.34 2.34 0 0 1 4.1 1.56c0 1.76-1.92 2.23-1.92 3.56"
|
||||
stroke="white"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.8" />
|
||||
<circle cx="12" cy="16.95" r="1" fill="white" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.0466 7.55147C16.2945 7.55147 16.5276 7.68131 16.6545 7.89673L18.2922 10.6292L21.3847 9.85316C21.6267 9.79414 21.8834 9.86496 22.0575 10.0391C22.2316 10.2132 22.3024 10.4728 22.2434 10.7119L21.4674 13.8014L24.2028 15.4392C24.4153 15.5661 24.5481 15.7992 24.5481 16.047C24.5481 16.2949 24.4182 16.528 24.2028 16.6549L21.4674 18.2956L22.2434 21.3852C22.3024 21.6272 22.2316 21.8839 22.0575 22.058C21.8834 22.2321 21.6237 22.3059 21.3847 22.2468L18.2952 21.4708L16.6574 24.2062C16.5305 24.4187 16.2974 24.5515 16.0495 24.5515C15.8017 24.5515 15.5685 24.4216 15.4417 24.2062L13.801 21.4708L10.7114 22.2468C10.4694 22.3059 10.2157 22.235 10.0386 22.0609C9.86155 21.8868 9.79073 21.6272 9.84975 21.3852L10.6229 18.2956L7.89037 16.6579C7.6779 16.528 7.54807 16.2979 7.54807 16.05C7.54807 15.8021 7.6779 15.569 7.89332 15.4421L10.6258 13.8044L9.84975 10.7119C9.79073 10.4699 9.8586 10.2161 10.0357 10.0391C10.2127 9.86201 10.4694 9.79414 10.7114 9.85316L13.801 10.6263L15.4387 7.89378L15.4918 7.81705C15.6246 7.64885 15.8282 7.54852 16.0466 7.54852V7.55147ZM16.0466 11.8007C13.7006 11.8007 11.7973 13.7041 11.7973 16.05C11.7973 18.3959 13.7006 20.2993 16.0466 20.2993C18.3925 20.2993 20.2959 18.3959 20.2959 16.05C20.2959 13.7041 18.3925 11.8007 16.0466 11.8007ZM16.0466 18.8828C14.4826 18.8828 13.2137 17.614 13.2137 16.05C13.2137 14.486 14.4826 13.2172 16.0466 13.2172C17.6106 13.2172 18.8794 14.486 18.8794 16.05C18.8794 17.614 17.6106 18.8828 16.0466 18.8828Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 25" fill="none">
|
||||
<path
|
||||
fill="#fff"
|
||||
fill-rule="evenodd"
|
||||
d="M0.253906 0.190815 4.81519 4.38773c.75537.69611 1.79321 1.00056 2.80566.82387l2.70068-.46925a4.1 4.1 0 0 1 1.13452 0l2.73901.47641c.99121.17194 2.00708-.11581 2.7605-.78328L21.7458.190815V16.0316c0 1.2657-.634 2.4465-1.6882 3.1462l-7.92115 5.2596c-.75439.5003-1.73706.4955-2.48706-.0131L1.91113 19.1802C.874756 18.4769.253906 17.3056.253906 16.0543V.190815Zm9.552004 9.552015v5.96997c0 .3988.19946.7725.53149.9934l3.58203 2.388c.5491.3654 1.2903.2173 1.656-.3307.36524-.54923.21704-1.29071-.33081-1.65607l-3.05078-2.03339V9.74283c0-.65909-.53491-1.194-1.19385-1.194-.65909 0-1.19409.53491-1.19409 1.194Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 769 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.0714 9.42857C12.7431 9.42857 13.2857 8.88594 13.2857 8.21429C13.2857 7.54263 12.7431 7 12.0714 7H9.64286C7.6317 7 6 8.6317 6 10.6429V20.3571C6 22.3683 7.6317 24 9.64286 24H12.0714C12.7431 24 13.2857 23.4574 13.2857 22.7857C13.2857 22.1141 12.7431 21.5714 12.0714 21.5714H9.64286C8.97121 21.5714 8.42857 21.0288 8.42857 20.3571V10.6429C8.42857 9.97121 8.97121 9.42857 9.64286 9.42857H12.0714ZM25.0719 16.3576C25.5462 15.8833 25.5462 15.1129 25.0719 14.6386L20.2147 9.78147C19.7404 9.30714 18.9701 9.30714 18.4958 9.78147C18.0214 10.2558 18.0214 11.0261 18.4958 11.5004L21.281 14.2857H13.2857C12.6141 14.2857 12.0714 14.8283 12.0714 15.5C12.0714 16.1717 12.6141 16.7143 13.2857 16.7143H21.281L18.4958 19.4996C18.0214 19.9739 18.0214 20.7442 18.4958 21.2185C18.9701 21.6929 19.7404 21.6929 20.2147 21.2185L25.0719 16.3614V16.3576Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 960 B |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.14583 8.71875C7.51204 8.71875 7 9.23079 7 9.86458C7 10.4984 7.51204 11.0104 8.14583 11.0104H11.2503C11.6908 12.0238 12.7005 12.7292 13.875 12.7292C15.0495 12.7292 16.0592 12.0238 16.4997 11.0104H24.1875C24.8213 11.0104 25.3333 10.4984 25.3333 9.86458C25.3333 9.23079 24.8213 8.71875 24.1875 8.71875H16.4997C16.0592 7.7054 15.0495 7 13.875 7C12.7005 7 11.6908 7.7054 11.2503 8.71875H8.14583ZM8.14583 14.4479C7.51204 14.4479 7 14.96 7 15.5938C7 16.2275 7.51204 16.7396 8.14583 16.7396H16.9795C17.4199 17.7529 18.4297 18.4583 19.6042 18.4583C20.7786 18.4583 21.7884 17.7529 22.2288 16.7396H24.1875C24.8213 16.7396 25.3333 16.2275 25.3333 15.5938C25.3333 14.96 24.8213 14.4479 24.1875 14.4479H22.2288C21.7884 13.4346 20.7786 12.7292 19.6042 12.7292C18.4297 12.7292 17.4199 13.4346 16.9795 14.4479H8.14583ZM8.14583 20.1771C7.51204 20.1771 7 20.6891 7 21.3229C7 21.9567 7.51204 22.4688 8.14583 22.4688H10.1045C10.5449 23.4821 11.5547 24.1875 12.7292 24.1875C13.9036 24.1875 14.9134 23.4821 15.3538 22.4688H24.1875C24.8213 22.4688 25.3333 21.9567 25.3333 21.3229C25.3333 20.6891 24.8213 20.1771 24.1875 20.1771H15.3538C14.9134 19.1637 13.9036 18.4583 12.7292 18.4583C11.5547 18.4583 10.5449 19.1637 10.1045 20.1771H8.14583Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,23 @@
|
||||
import { initCsrf } from './components/csrf.js';
|
||||
import { initFlash } from './components/flash.js';
|
||||
import { initForms } from './components/forms.js?v=20260322a';
|
||||
import { initModal } from './components/modal.js';
|
||||
import { initDashboard } from './components/dashboard.js';
|
||||
import { initSettingsSections } from './components/settings-sections.js';
|
||||
|
||||
function initApp() {
|
||||
initCsrf();
|
||||
initFlash();
|
||||
initForms();
|
||||
initModal();
|
||||
initDashboard();
|
||||
initSettingsSections();
|
||||
}
|
||||
|
||||
window.__stundenfuchsInitApp = initApp;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
} else {
|
||||
initApp();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function initCsrf() {
|
||||
const tokenInput = document.querySelector('input[name="csrf_token"]');
|
||||
const token = tokenInput ? tokenInput.value : null;
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('form[method="post"]').forEach((form) => {
|
||||
if (!form.querySelector('input[name="csrf_token"]')) {
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'csrf_token';
|
||||
hidden.value = token;
|
||||
form.appendChild(hidden);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
function isInteractiveTouchTarget(target) {
|
||||
if (!target || typeof target.closest !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest('a, button, input, select, textarea, summary, details, label, form'));
|
||||
}
|
||||
|
||||
function attachSwipeNavigation(target, prevUrl, nextUrl) {
|
||||
if (!target || !prevUrl || !nextUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minSwipeDistance = 60;
|
||||
const maxVerticalRatio = 1.25;
|
||||
const maxSwipeDuration = 900;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startAt = 0;
|
||||
let tracking = false;
|
||||
let navigating = false;
|
||||
|
||||
target.addEventListener('touchstart', (event) => {
|
||||
if (event.touches.length !== 1 || isInteractiveTouchTarget(event.target)) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
startAt = Date.now();
|
||||
tracking = true;
|
||||
}, { passive: true });
|
||||
|
||||
target.addEventListener('touchend', (event) => {
|
||||
if (!tracking || navigating || event.changedTouches.length !== 1) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
tracking = false;
|
||||
const touch = event.changedTouches[0];
|
||||
const deltaX = touch.clientX - startX;
|
||||
const deltaY = touch.clientY - startY;
|
||||
const absX = Math.abs(deltaX);
|
||||
const absY = Math.abs(deltaY);
|
||||
const duration = Date.now() - startAt;
|
||||
|
||||
if (duration > maxSwipeDuration || absX < minSwipeDistance || absX <= absY * maxVerticalRatio) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigating = true;
|
||||
if (deltaX < 0) {
|
||||
window.location.assign(nextUrl);
|
||||
} else {
|
||||
window.location.assign(prevUrl);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function initSwipeNavigation() {
|
||||
if (!window.matchMedia('(pointer: coarse)').matches && !('ontouchstart' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-component="swipe-nav"]').forEach((node) => {
|
||||
attachSwipeNavigation(node, node.dataset.prevUrl, node.dataset.nextUrl);
|
||||
});
|
||||
}
|
||||
|
||||
function initWarningBanner() {
|
||||
const warningBanner = document.querySelector('[data-component="workhours-warning"]');
|
||||
if (!warningBanner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const warningKey = warningBanner.getAttribute('data-workhours-warning') || '';
|
||||
const storageKey = warningKey ? `workhours-warning-dismissed:${warningKey}` : '';
|
||||
|
||||
if (storageKey && window.localStorage.getItem(storageKey) === '1') {
|
||||
warningBanner.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const closeButton = warningBanner.querySelector('[data-action="warning-close"]');
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', () => {
|
||||
warningBanner.remove();
|
||||
if (storageKey) {
|
||||
window.localStorage.setItem(storageKey, '1');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initWeeklyTargetEditor() {
|
||||
const form = document.querySelector('.weekly-target-form');
|
||||
const editor = document.querySelector('[data-component="weekly-target-editor"]');
|
||||
const toggleButtons = document.querySelectorAll('.js-toggle-weekly-target-editor');
|
||||
|
||||
if (form && toggleButtons.length && editor) {
|
||||
toggleButtons.forEach((toggleButton) => {
|
||||
toggleButton.addEventListener('click', () => {
|
||||
editor.classList.toggle('is-hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
const scopeSelect = form.querySelector("select[name='scope']");
|
||||
const hoursInput = form.querySelector("input[name='weekly_target_hours']");
|
||||
if (!scopeSelect || !hoursInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = scopeSelect.value;
|
||||
const hours = hoursInput.value;
|
||||
let scopeText = '';
|
||||
|
||||
if (scope === 'current_week') {
|
||||
scopeText = 'Nur die aktuell ausgewählte Woche';
|
||||
} else if (scope === 'all_weeks') {
|
||||
scopeText = 'Alle Wochen (Vergangenheit und Zukunft)';
|
||||
} else if (scope === 'from_current_week') {
|
||||
scopeText = 'Aktuelle Woche und alle zukünftigen Wochen';
|
||||
}
|
||||
|
||||
if (!scopeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`Wochen-Soll wirklich ändern?\nNeuer Wert: ${hours} h\nGültigkeit: ${scopeText}`);
|
||||
if (!confirmed) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor) {
|
||||
editor.classList.add('is-hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initDashboard() {
|
||||
initSwipeNavigation();
|
||||
initWarningBanner();
|
||||
initWeeklyTargetEditor();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function initFlash() {
|
||||
document.querySelectorAll('[data-component="flash"]').forEach((flash) => {
|
||||
flash.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && target.dataset.action === 'flash-close') {
|
||||
flash.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
async function refreshCurrentViewPreservingScroll() {
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
const response = await fetch(window.location.href, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'fetch' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`refresh_failed_${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const nextDocument = new DOMParser().parseFromString(html, 'text/html');
|
||||
['.site-header', '.app-page-actions-wrap', 'main.page'].forEach((selector) => {
|
||||
const currentNode = document.querySelector(selector);
|
||||
const nextNode = nextDocument.querySelector(selector);
|
||||
if (currentNode && nextNode) {
|
||||
currentNode.replaceWith(nextNode);
|
||||
}
|
||||
});
|
||||
|
||||
window.scrollTo({ left: scrollX, top: scrollY });
|
||||
if (typeof window.__stundenfuchsInitApp === 'function') {
|
||||
window.__stundenfuchsInitApp();
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimeToMinutes(value) {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value || '');
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
function formatMinutesToTime(value) {
|
||||
const minutes = Math.max(0, Math.min(24 * 60 - 1, Number(value) || 0));
|
||||
const hoursPart = String(Math.floor(minutes / 60)).padStart(2, '0');
|
||||
const minutesPart = String(minutes % 60).padStart(2, '0');
|
||||
return `${hoursPart}:${minutesPart}`;
|
||||
}
|
||||
|
||||
function requiredBreakMinutesForSpan(spanMinutes) {
|
||||
if (spanMinutes > 9 * 60) {
|
||||
return 45;
|
||||
}
|
||||
if (spanMinutes > 6 * 60) {
|
||||
return 30;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function requiredBreakMinutesForNetMinutes(netMinutes) {
|
||||
if (netMinutes > (9 * 60 - 45)) {
|
||||
return 45;
|
||||
}
|
||||
if (netMinutes > (6 * 60 - 30)) {
|
||||
return 30;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function initFullDayButtons() {
|
||||
document.querySelectorAll('[data-action="entry-apply-full-day"]').forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement) || button.dataset.fullDayBound === 'true') {
|
||||
return;
|
||||
}
|
||||
button.dataset.fullDayBound = 'true';
|
||||
|
||||
const form = button.closest('form[data-component="break-rules-form"]');
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startInput = form.querySelector('[data-break-input="start"]');
|
||||
const endInput = form.querySelector('[data-break-input="end"]');
|
||||
if (!(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const netMinutes = Number(form.dataset.fullDayNetMinutes || '');
|
||||
if (!Number.isFinite(netMinutes) || netMinutes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultStartValue = form.dataset.defaultStartTime || '08:30';
|
||||
const startMinutes = parseTimeToMinutes(startInput.value) ?? parseTimeToMinutes(defaultStartValue);
|
||||
if (startMinutes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoBreakEnabled = form.dataset.autoBreakEnabled === 'true';
|
||||
const configuredBreakMinutes = Number(form.dataset.defaultBreakMinutes || '0');
|
||||
const breakMinutes = autoBreakEnabled
|
||||
? requiredBreakMinutesForNetMinutes(netMinutes)
|
||||
: Math.max(0, configuredBreakMinutes);
|
||||
const endMinutes = startMinutes + netMinutes + breakMinutes;
|
||||
|
||||
startInput.value = formatMinutesToTime(startMinutes);
|
||||
endInput.value = formatMinutesToTime(endMinutes);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initBreakRuleForms() {
|
||||
document.querySelectorAll('form[data-component="break-rules-form"]').forEach((form) => {
|
||||
if (form.dataset.breakBound === 'true') {
|
||||
return;
|
||||
}
|
||||
form.dataset.breakBound = 'true';
|
||||
const autoBreakEnabled = form.dataset.autoBreakEnabled === 'true';
|
||||
|
||||
const modeInput = form.querySelector('[data-break-mode]');
|
||||
const startInput = form.querySelector('[data-break-input="start"]');
|
||||
const endInput = form.querySelector('[data-break-input="end"]');
|
||||
const breakInput = form.querySelector('[data-break-input="minutes"]');
|
||||
const statusNode = form.querySelector('[data-break-status]');
|
||||
const resetButton = form.querySelector('[data-action="break-reset-auto"]');
|
||||
|
||||
if (!(modeInput instanceof HTMLInputElement) || !(startInput instanceof HTMLInputElement) || !(endInput instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateStatus = () => {
|
||||
if (!statusNode) {
|
||||
return;
|
||||
}
|
||||
statusNode.textContent = modeInput.value === 'manual'
|
||||
? 'Pause manuell gesetzt. Gesetzliche Mindestpause wird nicht automatisch überschrieben.'
|
||||
: 'Gesetzliche Mindestpause nach deutschem Arbeitsrecht wird automatisch vorgeschlagen.';
|
||||
};
|
||||
|
||||
const applyAutoBreak = () => {
|
||||
if (!(breakInput instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
const startMinutes = parseTimeToMinutes(startInput.value);
|
||||
const endMinutes = parseTimeToMinutes(endInput.value);
|
||||
if (startMinutes === null || endMinutes === null || endMinutes <= startMinutes) {
|
||||
return;
|
||||
}
|
||||
modeInput.value = 'auto';
|
||||
breakInput.value = String(requiredBreakMinutesForSpan(endMinutes - startMinutes));
|
||||
updateStatus();
|
||||
};
|
||||
|
||||
const setManualMode = () => {
|
||||
modeInput.value = 'manual';
|
||||
updateStatus();
|
||||
};
|
||||
|
||||
startInput.addEventListener('input', () => {
|
||||
if (modeInput.value === 'auto') {
|
||||
applyAutoBreak();
|
||||
}
|
||||
});
|
||||
endInput.addEventListener('input', () => {
|
||||
if (modeInput.value === 'auto') {
|
||||
applyAutoBreak();
|
||||
}
|
||||
});
|
||||
if (breakInput instanceof HTMLInputElement) {
|
||||
breakInput.addEventListener('input', setManualMode);
|
||||
}
|
||||
if (resetButton) {
|
||||
resetButton.addEventListener('click', applyAutoBreak);
|
||||
}
|
||||
|
||||
if (!autoBreakEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!modeInput.value) {
|
||||
modeInput.value = 'auto';
|
||||
}
|
||||
if (modeInput.value === 'auto') {
|
||||
applyAutoBreak();
|
||||
} else {
|
||||
updateStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAsyncRefreshForms() {
|
||||
document.querySelectorAll('form[data-async-refresh="view"]').forEach((form) => {
|
||||
if (form.dataset.asyncBound === 'true') {
|
||||
return;
|
||||
}
|
||||
form.dataset.asyncBound = 'true';
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const submitter = event.submitter instanceof HTMLElement ? event.submitter : null;
|
||||
if (submitter) {
|
||||
submitter.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: new FormData(form),
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'fetch' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`submit_failed_${response.status}`);
|
||||
}
|
||||
await refreshCurrentViewPreservingScroll();
|
||||
} catch (error) {
|
||||
window.location.assign(window.location.href);
|
||||
} finally {
|
||||
if (submitter) {
|
||||
submitter.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initBreakSettingsForms() {
|
||||
document.querySelectorAll('form[data-component="break-settings-form"]').forEach((form) => {
|
||||
if (form.dataset.breakSettingsBound === 'true') {
|
||||
return;
|
||||
}
|
||||
form.dataset.breakSettingsBound = 'true';
|
||||
|
||||
const toggle = form.querySelector('[data-break-settings-toggle]');
|
||||
const minutesInput = form.querySelector('[data-break-settings-minutes]');
|
||||
|
||||
if (!(toggle instanceof HTMLInputElement) || !(minutesInput instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncDisabledState = () => {
|
||||
minutesInput.disabled = toggle.checked;
|
||||
};
|
||||
|
||||
toggle.addEventListener('change', syncDisabledState);
|
||||
syncDisabledState();
|
||||
});
|
||||
}
|
||||
|
||||
export function initForms() {
|
||||
document.querySelectorAll('form[data-confirm]').forEach((form) => {
|
||||
form.addEventListener('submit', (event) => {
|
||||
const message = form.getAttribute('data-confirm') || 'Aktion wirklich ausführen?';
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
initAsyncRefreshForms();
|
||||
initFullDayButtons();
|
||||
initBreakRuleForms();
|
||||
initBreakSettingsForms();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function initModal() {
|
||||
const modals = document.querySelectorAll('[data-component="modal"]');
|
||||
modals.forEach((modal) => {
|
||||
modal.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (target.dataset.action === 'modal-close') {
|
||||
modal.setAttribute('hidden', 'hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="modal-open"]').forEach((trigger) => {
|
||||
trigger.addEventListener('click', () => {
|
||||
const id = trigger.getAttribute('data-target');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.removeAttribute('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
const STORAGE_KEY = 'stundenfuchs:settingsSection';
|
||||
const DESKTOP_SYNC_MEDIA_QUERY = '(min-width: 51.26em)';
|
||||
|
||||
function shouldSyncGroups() {
|
||||
return window.matchMedia(DESKTOP_SYNC_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
function syncGroupState(section, isOpen) {
|
||||
const groupName = section.dataset.syncGroup || '';
|
||||
if (!groupName || !shouldSyncGroups()) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll(`[data-component="settings-section"][data-sync-group="${groupName}"]`).forEach((peer) => {
|
||||
if (peer instanceof HTMLDetailsElement && peer !== section) {
|
||||
peer.open = isOpen;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openSectionById(sectionId) {
|
||||
if (!sectionId) {
|
||||
return;
|
||||
}
|
||||
const target = document.getElementById(sectionId);
|
||||
if (!(target instanceof HTMLDetailsElement)) {
|
||||
return;
|
||||
}
|
||||
target.open = true;
|
||||
syncGroupState(target, true);
|
||||
}
|
||||
|
||||
export function initSettingsSections() {
|
||||
const sections = Array.from(document.querySelectorAll('[data-component="settings-section"]'));
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hashTarget = window.location.hash ? window.location.hash.slice(1) : '';
|
||||
const storedTarget = window.sessionStorage.getItem(STORAGE_KEY) || '';
|
||||
openSectionById(hashTarget || storedTarget);
|
||||
if (storedTarget) {
|
||||
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (!(section instanceof HTMLDetailsElement) || !section.id) {
|
||||
return;
|
||||
}
|
||||
section.addEventListener('toggle', () => {
|
||||
syncGroupState(section, section.open);
|
||||
});
|
||||
section.querySelectorAll('form').forEach((form) => {
|
||||
form.addEventListener('submit', () => {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, section.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Stundenfuchs",
|
||||
"short_name": "Stundenfuchs",
|
||||
"id": "/",
|
||||
"start_url": "/dashboard",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#2c2d2f",
|
||||
"theme_color": "#2c2d2f",
|
||||
"lang": "de-DE",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/pwa-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/pwa-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/* legacy stylesheet deprecated. Use /static/css/app.css only. */
|
||||
@@ -0,0 +1 @@
|
||||
// legacy script deprecated. Use /static/js/app.js only.
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2c2d2f" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="{{ app_title }}" />
|
||||
{% set __page_title %}{% block title %}{{ app_name }}{% endblock %}{% endset %}
|
||||
<title>
|
||||
{% if __page_title | trim == app_name %}{{ app_title }}{% else %}{{ app_title }} - {{ __page_title | trim }}{% endif %}
|
||||
</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest?v={{ asset_version }}" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/Logo.svg" />
|
||||
<link rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/static/icons/favicon-32.png?v={{ asset_version }}" />
|
||||
<link rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/static/icons/favicon-16.png?v={{ asset_version }}" />
|
||||
<link rel="shortcut icon"
|
||||
href="/static/icons/favicon.ico?v={{ asset_version }}" />
|
||||
<link rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="{% if app_env != 'production' %}/static/icons/apple-touch-icon-stage.png{% else %}/static/icons/apple-touch-icon.png{% endif %}?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}" />
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body class="app-theme {% block body_class %}{% endblock %}">
|
||||
{% include "partials/header.html" %}
|
||||
<div class="container app-page-actions-wrap">{%- block page_actions -%}{%- endblock -%}</div>
|
||||
<main class="container page {% block page_class %}{% endblock %}">
|
||||
{% include "partials/flash.html" %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% include "partials/footer.html" %}
|
||||
{% include "partials/version_badge.html" %}
|
||||
<script type="module" src="/static/js/app.js?v={{ asset_version }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button, link_button %}
|
||||
{% from "ui/form_field.html" import input_field, select_field, textarea_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}Mehrfacheingabe{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header('Mehrere Tage / Wochen bearbeiten', 'Zeitraum und Wochentage auswählen, dann Zeiten gesammelt für alle passenden Tage setzen.') }}
|
||||
{% call card('form-card full-width') %}
|
||||
<form method="post"
|
||||
action="/bulk-entry"
|
||||
class="stack"
|
||||
data-component="break-rules-form"
|
||||
data-auto-break-enabled="{{ 'true' if user.automatic_break_rules_enabled else 'false' }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="break_mode" value="{{ break_mode }}" data-break-mode />
|
||||
<div class="inline-grid">
|
||||
{{ input_field('Von', 'from_date', type='date', value=from_date, required=true) }}
|
||||
{{ input_field('Bis', 'to_date', type='date', value=to_date, required=true) }}
|
||||
</div>
|
||||
<fieldset class="weekday-fieldset">
|
||||
<legend>Wochentage</legend>
|
||||
<div class="weekday-grid">
|
||||
{% for option in weekday_options %}
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="weekdays_values"
|
||||
value="{{ option.value }}"
|
||||
{% if option.value in weekdays_selected %}checked{% endif %} />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="inline-grid">
|
||||
{{ input_field('Beginn', 'start_time', type='time', value=start_time, required=true, attrs='data-break-input=\"start\"') }}
|
||||
{{ input_field('Ende', 'end_time', type='time', value=end_time, required=true, attrs='data-break-input=\"end\"') }}
|
||||
</div>
|
||||
<div class="inline-grid">
|
||||
{{ input_field('Pause (Minuten)', 'break_minutes', type='number', value=break_minutes, required=true, attrs='min="0" step="1" data-break-input=\"minutes\"') }}
|
||||
{{ select_field('Modus', 'mode', [
|
||||
{'value': 'only_missing', 'label': 'Nur leere Tage anlegen'},
|
||||
{'value': 'upsert', 'label': 'Bestehende Einträge aktualisieren + fehlende anlegen'}
|
||||
], bulk_mode, required=true) }}
|
||||
</div>
|
||||
{% if user.automatic_break_rules_enabled %}
|
||||
<div class="form-field form-field--hint stack-xs" data-component="break-rules-status">
|
||||
<p class="muted" data-break-status>
|
||||
{% if break_mode == 'manual' %}
|
||||
Pause manuell gesetzt. Gesetzliche Mindestpause wird nicht automatisch überschrieben.
|
||||
{% else %}
|
||||
Gesetzliche Mindestpause nach deutschem Arbeitsrecht wird automatisch vorgeschlagen.
|
||||
{% endif %}
|
||||
</p>
|
||||
<button type="button" class="button ghost" data-action="break-reset-auto">Automatische Pause erneut anwenden</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ textarea_field('Notiz (optional)', 'notes', notes, 3) }}
|
||||
<div class="nav-row">
|
||||
{{ button('Mehrfacheingabe speichern', type='submit') }}
|
||||
{{ link_button('Zurück', '/dashboard', 'ghost') }}
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/button.html" import button %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
|
||||
{% block title %}Kontakt{% endblock %}
|
||||
{% block page_class %}contact-page{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header("Kontakt", "Schicke eine Nachricht bei Problemen, Fehlermeldungen oder Funktionswünschen. Antworten erfolgen per E-Mail.") }}
|
||||
|
||||
<div class="contact-grid">
|
||||
{% call card('contact-card') %}
|
||||
<form method="post" action="/kontakt" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="started_at" value="{{ contact_started_at }}" />
|
||||
<label class="contact-honeypot" aria-hidden="true">
|
||||
<span>Bitte leer lassen</span>
|
||||
<input type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<div class="inline-grid">
|
||||
<label>
|
||||
Name (optional)
|
||||
<input type="text" name="name" value="{{ contact_name }}" maxlength="120" />
|
||||
</label>
|
||||
<label>
|
||||
E-Mail-Adresse
|
||||
<input type="email" name="email" value="{{ contact_email }}" maxlength="254" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Kategorie
|
||||
<select name="category" required>
|
||||
{% for option in category_options %}
|
||||
<option value="{{ option.value }}"
|
||||
{% if option.value == contact_category %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Betreff
|
||||
<input type="text" name="subject" value="{{ contact_subject }}" maxlength="180" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Nachricht
|
||||
<textarea name="message" rows="9" maxlength="5000" required>{{ contact_message }}</textarea>
|
||||
</label>
|
||||
|
||||
<p class="muted">
|
||||
Bitte keine sensiblen Passwörter oder Zugangsdaten mitsenden. Anhänge sind in dieser ersten Version noch nicht möglich.
|
||||
</p>
|
||||
|
||||
{{ button("Nachricht senden", type="submit") }}
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
{% call card('contact-card contact-info-card') %}
|
||||
<h2>Wofür ist das gedacht?</h2>
|
||||
<ul class="contact-info-list">
|
||||
<li>Fehler melden, wenn etwas nicht wie erwartet funktioniert</li>
|
||||
<li>Funktionswünsche einreichen</li>
|
||||
<li>Fragen zur Nutzung oder zu Einstellungen stellen</li>
|
||||
</ul>
|
||||
<p class="muted">
|
||||
Nachrichten werden intern als Ticket gespeichert. So gehen Rückmeldungen nicht verloren und können strukturiert bearbeitet werden.
|
||||
</p>
|
||||
<p class="muted">
|
||||
Hinweise zu Anbieter und Datenschutz findest du ebenfalls unten im Footer über <a href="/impressum">Impressum</a> und <a href="/datenschutz">Datenschutz</a>.
|
||||
</p>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/day_row.html" import day_row with context %}
|
||||
{% from "ui/kpi_bar.html" import kpi_bar with context %}
|
||||
{% from "ui/week_header_bar.html" import week_header_bar with context %}
|
||||
{% from "ui/icon_button.html" import icon_link with context %}
|
||||
{% from "ui/warning_components.html" import workhours_target_warning_banner with context %}
|
||||
{% block title %}Wochenansicht{% endblock %}
|
||||
{% block body_class %}dashboard-theme{% endblock %}
|
||||
{% block page_class %}dashboard-page{% endblock %}
|
||||
{% block content %}
|
||||
{% set return_to = request.url.path ~ ('?' ~ request.url.query if request.url.query else '') %}
|
||||
|
||||
<div class="week-view-shell">
|
||||
{% call week_header_bar('/dashboard?date=' ~ previous_week.isoformat(), '/dashboard?date=' ~ next_week.isoformat(), 'KW ' ~ week.week_start.isocalendar()[1] ~ ' (' ~ week.week_start.strftime('%d.%m.') ~ ' - ' ~ week.week_end.strftime('%d.%m.%Y') ~ ')') %}
|
||||
<form method="post" action="/vacation/week/toggle" class="inline-form" data-async-refresh="view">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="week_start" value="{{ week.week_start.isoformat() }}" />
|
||||
<input type="hidden" name="week_end" value="{{ week.week_end.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<button type="submit"
|
||||
class="week-vacation-button {% if week.is_vacation_week %}is-active{% endif %}"
|
||||
title="Urlaub für ganze Woche umschalten"
|
||||
aria-label="Urlaub für ganze Woche umschalten">Urlaub</button>
|
||||
</form>
|
||||
{{ icon_link('/entry/new?date=' ~ selected_date.isoformat(), '/static/icons/add.svg', 'Tag hinzufügen') }}
|
||||
{{ icon_link('/bulk-entry?from=' ~ week.week_start.isoformat() ~ '&to=' ~ week.week_end.isoformat(), '/static/icons/batch.svg', 'Mehrere Tage bearbeiten') }}
|
||||
{{ icon_link('/export', '/static/icons/export.svg', 'Export') }}
|
||||
{% endcall %}
|
||||
|
||||
{{ kpi_bar([
|
||||
{'label': 'IST', 'value': ('%.2f'|format(week.weekly_ist / 60) )|replace('.00', '')},
|
||||
{'label': 'SOLL', 'value': ('%.2f'|format(week.weekly_soll / 60))|replace('.00', '')},
|
||||
{'label': 'DELTA', 'value': ('%.2f'|format(week.weekly_delta / 60))|replace('.00', ''), 'value_class': 'negative' if week.weekly_delta < 0 else 'positive'},
|
||||
{'label': 'KUMULIERT', 'value': ('%.2f'|format(week.cumulative_delta / 60))|replace('.00', ''), 'value_class': 'negative' if week.cumulative_delta < 0 else 'positive'}
|
||||
], 'kpi-bar--week') }}
|
||||
|
||||
{{ workhours_target_warning_banner(workhours_target_warning) }}
|
||||
|
||||
<section class="day-list day-list--week">
|
||||
{% for day in week.days %}
|
||||
{{ day_row(day, csrf_token, weekday_name_de(day.date) ~ ', ' ~ day.date.strftime('%d.%m.%Y'), return_to, 'week') }}
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import link_button %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header(title, selected_date.strftime('%d.%m.%Y')) }}
|
||||
{% call card('form-card') %}
|
||||
<div class="stack">
|
||||
{% if has_entry %}
|
||||
<p class="muted">An diesem Tag ist bereits regulaere Arbeitszeit eingetragen. Bitte bearbeite zuerst den Zeiteintrag.</p>
|
||||
<div class="nav-row">
|
||||
{{ link_button('Zeiteintrag bearbeiten', '/entry/' ~ existing_entry_id ~ '/edit', 'primary') }}
|
||||
{{ link_button('Zurueck', return_to, 'ghost') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">
|
||||
{% if is_active %}
|
||||
{{ title }} ist aktuell gesetzt. Mit dem Speichern entfernst du diesen Status wieder.
|
||||
{% else %}
|
||||
{% if current_status_label %}
|
||||
Aktuell ist {{ current_status_label }} gesetzt. Mit dem Speichern wird dieser Status ersetzt.
|
||||
{% else %}
|
||||
Hier kannst du diesen Status direkt für den ausgewählten Tag setzen oder wieder entfernen.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if day_overtime_adjustment_minutes %}
|
||||
<p class="muted">
|
||||
Zusaetzlicher Stundenausgleich aktiv:
|
||||
<strong>{{ '+' if day_overtime_adjustment_minutes > 0 else '' }}{{ minutes_to_hhmm(day_overtime_adjustment_minutes) }}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ action_url }}" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
{% if status_key != 'vacation' %}
|
||||
<input type="hidden" name="status" value="{{ status_key }}" />
|
||||
{% endif %}
|
||||
<div class="nav-row">
|
||||
<button type="submit" class="btn btn--primary">{{ title }} {% if is_active %}entfernen{% else %}speichern{% endif %}</button>
|
||||
{{ link_button('Abbrechen', return_to, 'ghost') }}
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button %}
|
||||
{% from "ui/form_field.html" import input_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}E-Mail bestätigen{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header("Bestätigungslink anfordern") }}
|
||||
{% call card('auth-card') %}
|
||||
<form action="/verify-email/resend" method="post" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
{{ input_field('E-Mail-Adresse', 'email', type='email', required=true, autocomplete='username') }}
|
||||
{{ button('Link senden', type='submit') }}
|
||||
</form>
|
||||
<p>
|
||||
<a href="/login">Zur Anmeldung</a>
|
||||
</p>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% from "ui/flash.html" import alert %}
|
||||
{% block title %}E-Mail bestätigen{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header("E-Mail-Bestätigung") }}
|
||||
{% call card('auth-card') %}
|
||||
{% if success %}
|
||||
{{ alert(message, 'success') }}
|
||||
{% else %}
|
||||
{{ alert(message, 'error') }}
|
||||
{% endif %}
|
||||
<p>
|
||||
<a href="/verify-email/resend">Neuen Bestätigungslink anfordern</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/login">Zur Anmeldung</a>
|
||||
</p>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button, link_button %}
|
||||
{% from "ui/form_field.html" import input_field, textarea_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header(title, "Nur fuer regulaere Arbeitszeit." ~ (" Gesetzliche Pausen koennen automatisch beruecksichtigt werden." if user.automatic_break_rules_enabled else "")) }}
|
||||
{% call card('form-card') %}
|
||||
<form method="post"
|
||||
action="{{ action_url }}"
|
||||
class="stack"
|
||||
data-component="break-rules-form"
|
||||
data-auto-break-enabled="{{ 'true' if user.automatic_break_rules_enabled else 'false' }}"
|
||||
data-full-day-net-minutes="{{ full_day_net_minutes if full_day_net_minutes is not none else '' }}"
|
||||
data-default-break-minutes="{{ user.default_break_minutes }}"
|
||||
data-default-start-time="08:30">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<input type="hidden" name="break_mode" value="{{ entry.break_mode if entry else ('auto' if user.automatic_break_rules_enabled else 'manual') }}" data-break-mode />
|
||||
{{ input_field('Datum', 'date', type='date', value=(entry.date if entry and entry.date else selected_date.isoformat()), required=true) }}
|
||||
{{ input_field('Arbeitsbeginn', 'start_time', type='time', value=(entry.start_time if entry else ''), required=true, attrs='data-break-input=\"start\"') }}
|
||||
{{ input_field('Arbeitsende', 'end_time', type='time', value=(entry.end_time if entry else ''), required=true, attrs='data-break-input=\"end\"') }}
|
||||
{% if full_day_net_minutes is not none %}
|
||||
<div class="nav-row">
|
||||
<button type="button" class="button ghost" data-action="entry-apply-full-day">Ganzer Tag</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ input_field('Pause in Minuten', 'break_minutes', type='number', value=(entry.break_minutes if entry and entry.break_minutes is not none else 0), required=true, attrs='min=\"0\" step=\"1\" data-break-input=\"minutes\"') }}
|
||||
{% if user.automatic_break_rules_enabled %}
|
||||
<div class="form-field form-field--hint stack-xs" data-component="break-rules-status">
|
||||
<p class="muted" data-break-status>
|
||||
{% if entry and entry.break_mode == 'manual' %}
|
||||
Pause manuell gesetzt. Gesetzliche Mindestpause wird nicht automatisch überschrieben.
|
||||
{% else %}
|
||||
Gesetzliche Mindestpause nach deutschem Arbeitsrecht wird automatisch vorgeschlagen.
|
||||
{% endif %}
|
||||
</p>
|
||||
<button type="button" class="button ghost" data-action="break-reset-auto">Automatische Pause erneut anwenden</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ textarea_field('Notiz (optional)', 'notes', (entry.notes if entry else ''), 3) }}
|
||||
<p class="muted">Mit gespeicherter Arbeitszeit werden Urlaub, Feiertag, Krankheit und Stundenausgleich fuer diesen Tag entfernt.</p>
|
||||
<div class="nav-row">
|
||||
{{ button('Speichern', type='submit') }}
|
||||
{{ link_button('Abbrechen', return_to, 'ghost') }}
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button, link_button %}
|
||||
{% from "ui/form_field.html" import input_field, select_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}Export{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header('Export', 'Zeitraum auf den Tag genau wählen und als Excel oder PDF herunterladen.') }}
|
||||
{% call card('form-card') %}
|
||||
<form method="post" action="/export" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="inline-grid">
|
||||
{{ input_field('Von', 'from_date', type='date', value=from_date, required=true) }}
|
||||
{{ input_field('Bis', 'to_date', type='date', value=to_date, required=true) }}
|
||||
</div>
|
||||
{{ select_field('Format', 'format', [
|
||||
{'value': 'xlsx', 'label': 'Excel (.xlsx)'},
|
||||
{'value': 'pdf', 'label': 'PDF (.pdf)'}
|
||||
], 'xlsx', required=true) }}
|
||||
<div class="nav-row">
|
||||
{{ button('Export starten', type='submit') }}
|
||||
{{ link_button('Zurück', '/dashboard', 'ghost') }}
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,206 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% from "ui/help_section.html" import help_section %}
|
||||
|
||||
{% block title %}Hilfe{% endblock %}
|
||||
{% block page_class %}help-page{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header("Hilfe", "Hier findest du alle wichtigen Funktionen des Stundenfuchs verständlich erklärt.") }}
|
||||
|
||||
<section class="help-intro">
|
||||
<p>
|
||||
Stundenfuchs hilft dir dabei, Arbeitszeiten, Urlaub, Feiertage, Krankheitstage und Überstunden an einem Ort zu verwalten.
|
||||
Die App ist so aufgebaut, dass du im Alltag schnell arbeiten kannst, ohne jede Berechnung selbst im Kopf machen zu müssen.
|
||||
</p>
|
||||
<p>
|
||||
Wenn du neu startest, beginne am besten mit der Wochenansicht. Dort kannst du Tage eintragen, bearbeiten und direkt sehen,
|
||||
wie sich deine Stunden verändern.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="help-guides">
|
||||
<header class="help-guides__header">
|
||||
<h2>Schritt-für-Schritt-Anleitungen</h2>
|
||||
<p class="muted">Die wichtigsten Abläufe einmal komplett erklärt. Du kannst diese Anleitungen direkt nacheinander durchgehen.</p>
|
||||
</header>
|
||||
<div class="help-guides__grid">
|
||||
<article class="help-guide-card">
|
||||
<h3>Einen normalen Arbeitstag eintragen</h3>
|
||||
<ol class="help-steps">
|
||||
<li>Gehe in die Wochen- oder Monatsansicht.</li>
|
||||
<li>Klicke beim gewünschten Tag auf <strong>+</strong> oder auf <strong>Bearbeiten</strong>.</li>
|
||||
<li>Wähle <strong>Zeit</strong>, wenn du einen normalen Arbeitstag erfassen möchtest.</li>
|
||||
<li>Trage Arbeitsbeginn und Arbeitsende ein. Wenn du die automatische Pausenregel aktiviert hast, schlägt Stundenfuchs die gesetzliche Mindestpause automatisch vor.</li>
|
||||
<li>Du kannst die Pause trotzdem jederzeit manuell ändern. Dann bleibt dein eigener Wert maßgeblich.</li>
|
||||
<li>Speichere den Eintrag. Die App berechnet Nettozeit, Ist-Stunden und Delta automatisch.</li>
|
||||
</ol>
|
||||
</article>
|
||||
<article class="help-guide-card">
|
||||
<h3>Urlaub, Feiertag oder Krankheit eintragen</h3>
|
||||
<ol class="help-steps">
|
||||
<li>Klicke beim gewünschten Tag auf <strong>+</strong>.</li>
|
||||
<li>Wähle <strong>Urlaub</strong>, <strong>Feiertag</strong> oder <strong>Krankheit</strong>.</li>
|
||||
<li>Der Status wird sofort gesetzt. Danach erscheint der Tag direkt in der Liste mit dem passenden Kürzel.</li>
|
||||
<li>Wenn nötig, kannst du den Status später wieder ändern oder entfernen.</li>
|
||||
</ol>
|
||||
</article>
|
||||
<article class="help-guide-card">
|
||||
<h3>Stundenausgleich buchen</h3>
|
||||
<ol class="help-steps">
|
||||
<li>Klicke am gewünschten Tag auf <strong>+</strong> und wähle <strong>Stundenausgleich</strong>.</li>
|
||||
<li>Entscheide dich für eine der drei Varianten: <strong>Stunden</strong>, <strong>Von-Bis Uhrzeit</strong> oder <strong>Ganzer Tag</strong>.</li>
|
||||
<li>Wähle, ob der Ausgleich positiv oder negativ sein soll.</li>
|
||||
<li>Speichere den Eintrag. In der Liste erscheint der Tag danach mit dem <strong>S</strong>-Symbol.</li>
|
||||
<li>Der Ausgleich verändert direkt deinen Überstundenstand, ohne als normale Arbeitszeit zu zählen.</li>
|
||||
</ol>
|
||||
</article>
|
||||
<article class="help-guide-card">
|
||||
<h3>Arbeitsstunden-Counter einrichten</h3>
|
||||
<ol class="help-steps">
|
||||
<li>Öffne die <strong>Einstellungen</strong>.</li>
|
||||
<li>Gehe zum Bereich <strong>Arbeitsstunden-Counter</strong>.</li>
|
||||
<li>Aktiviere den Counter und trage Start- und Enddatum ein.</li>
|
||||
<li>Optional kannst du Zusatzstunden, ein Ziel und die Anzeige im Header aktivieren.</li>
|
||||
<li>Speichere die Einstellungen. Danach siehst du deinen Stand direkt im Einstellungsbereich und auf Wunsch oben im Header.</li>
|
||||
</ol>
|
||||
</article>
|
||||
<article class="help-guide-card">
|
||||
<h3>Backup importieren</h3>
|
||||
<ol class="help-steps">
|
||||
<li>Öffne die <strong>Einstellungen</strong> oder nutze den Backup-Upload direkt in der Registrierung.</li>
|
||||
<li>Wähle deine Backup-Datei aus.</li>
|
||||
<li>Entscheide, ob du deine Daten <strong>zusammenführen</strong> oder <strong>vollständig ersetzen</strong> möchtest.</li>
|
||||
<li>Prüfe die Vorschau mit Datensatzanzahl und Konflikten.</li>
|
||||
<li>Starte erst danach den eigentlichen Import.</li>
|
||||
</ol>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-grid">
|
||||
{% call help_section("Schnellstart", "So kommst du am schnellsten zu einem sauberen Stundenstand.") %}
|
||||
<ol class="help-steps">
|
||||
<li>Lege in den Einstellungen deine Wochenstunden und relevanten Arbeitstage fest.</li>
|
||||
<li>Trage in der Wochenansicht deine regulären Arbeitstage ein oder markiere Urlaub, Feiertag oder Krankheit.</li>
|
||||
<li>Nutze den Stundenausgleich, wenn du Plus- oder Minusstunden ohne normale Arbeitszeit buchen möchtest.</li>
|
||||
<li>Kontrolliere oben in den Kacheln dein aktuelles Delta und den kumulierten Stand.</li>
|
||||
</ol>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Die Kopfzeile", "Die Leiste oben zeigt dir jederzeit die wichtigsten Werte.") %}
|
||||
<ul class="help-list">
|
||||
<li><strong>Urlaub</strong>: zeigt den verbleibenden Urlaub im Verhältnis zum Gesamturlaub.</li>
|
||||
<li><strong>Arbeitsstunden</strong>: erscheint nur, wenn du den Arbeitsstunden-Counter aktiviert hast.</li>
|
||||
<li><strong>Kumuliert</strong>: zeigt deinen gesamten Überstundenstand bis heute.</li>
|
||||
<li><strong>Woche / Monat</strong>: wechselt zwischen den beiden Hauptansichten.</li>
|
||||
<li><strong>Einstellungen</strong>: hier legst du alle persönlichen Regeln fest.</li>
|
||||
<li><strong>Hilfe</strong>: öffnet diese Erklärung.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Wochenansicht", "Die Wochenansicht ist der schnellste Weg für den Alltag.") %}
|
||||
<ul class="help-list">
|
||||
<li>Oben siehst du die ausgewählte Kalenderwoche mit Datumsspanne.</li>
|
||||
<li>Die Kacheln <strong>IST</strong>, <strong>SOLL</strong>, <strong>DELTA</strong> und <strong>KUMULIERT</strong> werden automatisch berechnet.</li>
|
||||
<li>Jeder Tag erscheint als eigene Zeile. Dort kannst du direkt sehen, was eingetragen ist.</li>
|
||||
<li>Leere Tage erkennst du an <strong>Keinen Eintrag</strong> und dem <strong>+</strong>-Button rechts.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Monatsansicht", "Die Monatsansicht eignet sich gut für Rückblicke und längere Zeiträume.") %}
|
||||
<ul class="help-list">
|
||||
<li>Der Monat ist in Kalenderwochen gegliedert, damit du längere Zeiträume übersichtlich prüfen kannst.</li>
|
||||
<li>Jede Woche zeigt eine eigene Zusammenfassung mit Ist, Soll, Delta und Urlaubstagen.</li>
|
||||
<li>Du kannst Tage auch hier direkt bearbeiten, ohne in die Wochenansicht zu wechseln.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Arbeitszeit eintragen", "Für normale Arbeitstage nutzt du immer den Zeiteintrag.") %}
|
||||
<ul class="help-list">
|
||||
<li>Gib <strong>Arbeitsbeginn</strong> und <strong>Arbeitsende</strong> an. Die Pause kannst du direkt mitpflegen.</li>
|
||||
<li>Wenn du in den Einstellungen die automatische Pausenregel aktiviert hast, setzt Stundenfuchs nach deutschem Arbeitsrecht automatisch mindestens 30 oder 45 Minuten Pause, sobald die Arbeitszeit lang genug ist.</li>
|
||||
<li>Manuelle Änderungen an der Pause haben immer Vorrang vor dem automatisch vorgeschlagenen Wert.</li>
|
||||
<li>Die App berechnet daraus automatisch deine Netto-Arbeitszeit.</li>
|
||||
<li>Reguläre Arbeitszeit und Stundenausgleich schließen sich aus. Ein Tag ist entweder Arbeitszeit oder Ausgleich.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Urlaub, Feiertag und Krankheit", "Diese Tagesarten beeinflussen deine Berechnungen anders als normale Arbeitszeit.") %}
|
||||
<ul class="help-list">
|
||||
<li><strong>Urlaub</strong>: reduziert normalerweise dein Soll und zählt als Urlaubstag.</li>
|
||||
<li><strong>Feiertag</strong>: markiert einen arbeitsfreien Feiertag ohne Urlaub zu verbrauchen.</li>
|
||||
<li><strong>Krankheit</strong>: markiert einen Krankheitstag ohne Urlaub zu verbrauchen.</li>
|
||||
<li>In den Einstellungen kannst du festlegen, ob diese Tage stundenmäßig wie reguläre Arbeitstage behandelt werden sollen.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Stundenausgleich (S)", "Damit kannst du Überstunden oder Minusstunden direkt verändern, ohne normale Arbeitszeit einzutragen.") %}
|
||||
<ul class="help-list">
|
||||
<li>Du kannst den Wert manuell als <strong>+/- Stunden</strong> eintragen.</li>
|
||||
<li>Alternativ kannst du eine <strong>Von-Bis Uhrzeit</strong> wählen, wenn du die Dauer nicht selbst ausrechnen möchtest.</li>
|
||||
<li>Außerdem gibt es <strong>Ganzer Tag +</strong> und <strong>Ganzer Tag -</strong>. Die App nutzt dafür dein Wochenziel und deine relevanten Arbeitstage.</li>
|
||||
<li>Stundenausgleich darf mit Urlaub, Feiertag oder Krankheit kombiniert werden, aber nicht mit normaler Arbeitszeit.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Arbeitsstunden-Counter", "Dieser Bereich ist unabhängig von deinem Überstundenkonto.") %}
|
||||
<ul class="help-list">
|
||||
<li>Der Counter ist für längere Zeiträume gedacht, zum Beispiel für Praxisstunden oder ein Anerkennungsjahr.</li>
|
||||
<li>Du legst Startdatum, Enddatum und optional ein Stundenziel fest.</li>
|
||||
<li>Zusätzliche bereits geleistete Stunden kannst du separat eintragen, zum Beispiel aus früheren Praktika.</li>
|
||||
<li>Wenn du es aktivierst, kann der Counter auch im Header angezeigt werden.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Automatischer Modus", "Wenn du nicht jeden Standard-Arbeitstag einzeln eintragen möchtest.") %}
|
||||
<ul class="help-list">
|
||||
<li>Im automatischen Modus füllt die App fehlende reguläre Arbeitstage bis einschließlich heute nach deinen Einstellungen automatisch aus.</li>
|
||||
<li>Zukünftige Tage werden dabei bewusst nicht vorausgefüllt.</li>
|
||||
<li>Du passt dann nur noch Abweichungen an, zum Beispiel Urlaub, Krankheit oder andere Zeiten.</li>
|
||||
<li>Wenn du in den Modus wechselst oder später zurück auf manuell gehst, werden automatisch erzeugte zukünftige Einträge entfernt.</li>
|
||||
<li>Zusätzlich gibt es einen serverseitigen Tagesabgleich, damit fehlende Tage auch ohne deine Anmeldung nachgezogen werden können.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Einstellungen", "Hier steuerst du die Regeln, nach denen Stunden berechnet werden.") %}
|
||||
<ul class="help-list">
|
||||
<li><strong>Urlaub</strong>: Gesamturlaubstage, Resturlaub im Header und Urlaubszeiträume.</li>
|
||||
<li><strong>Wochenstunden</strong>: dein allgemeines Wochenziel, das für Soll-Berechnungen genutzt wird.</li>
|
||||
<li><strong>Relevante Arbeitstage</strong>: bestimmt, an welchen Wochentagen deine Sollstunden verteilt werden.</li>
|
||||
<li><strong>Überstunden-Regeln</strong>: Startdatum, Verfall und andere Regeln für dein Delta.</li>
|
||||
<li><strong>Arbeitsstunden-Counter</strong>: langer Zeitraum, Ziel und optionale Anzeige im Header.</li>
|
||||
<li><strong>Datenexport</strong>: kompletter Export aller erfassten Daten als Excel, PDF oder Backup-Datei.</li>
|
||||
<li><strong>Backup importieren</strong>: Sichere Daten aus einer Backup-Datei wieder einspielen, entweder ergänzend oder als vollständigen Ersatz deiner bisherigen Arbeitsdaten.</li>
|
||||
<li><strong>Konto löschen</strong>: entfernt dein Konto und alle zugehörigen Daten dauerhaft nach Sicherheitsbestätigung.</li>
|
||||
<li><strong>Footer</strong>: Über Kontakt, Impressum und Datenschutz erreichst du die öffentlichen Service- und Rechtstexte.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Backup und Wiederherstellung", "So sicherst du deine Daten und spielst sie später wieder ein.") %}
|
||||
<ul class="help-list">
|
||||
<li>Die Backup-Datei enthält nur arbeitsbezogene Daten und fachliche Einstellungen, aber keine Passwörter, MFA-Daten oder deine E-Mail-Adresse.</li>
|
||||
<li>Ein Backup kannst du direkt bei der Registrierung importieren oder später in den Einstellungen einspielen.</li>
|
||||
<li><strong>Zusammenführen</strong> ergänzt nur konfliktfreie Inhalte. Bereits vorhandene Tagesdaten bleiben erhalten.</li>
|
||||
<li><strong>Alle bisherigen Daten ersetzen</strong> löscht zuerst deine importierbaren Arbeitsdaten und übernimmt dann den Inhalt des Backups.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
|
||||
{% call help_section("Praktische Tipps", "Diese Hinweise vermeiden typische Fehler im Alltag.") %}
|
||||
<ul class="help-list">
|
||||
<li>Trage Zeiten möglichst zeitnah ein, damit Wochen- und Monatswerte korrekt bleiben.</li>
|
||||
<li>Nutze Urlaub, Feiertag und Krankheit nur dann, wenn an dem Tag keine normale Arbeitszeit eingetragen wird.</li>
|
||||
<li>Verwende Stundenausgleich nur für direkte Saldo-Korrekturen, nicht für normale Arbeitstage.</li>
|
||||
<li>Wenn ein Wert unerwartet wirkt, prüfe zuerst Wochenstunden, relevante Arbeitstage und Sondertage in den Einstellungen.</li>
|
||||
</ul>
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<section class="help-callout">
|
||||
<h2>Wenn etwas nicht passt</h2>
|
||||
<p>
|
||||
Die meisten Abweichungen entstehen durch falsche Wochenstunden, unpassende relevante Arbeitstage oder einen gesetzten Sonderstatus.
|
||||
Prüfe in diesem Fall zuerst die Tageszeile und danach die Einstellungen. Wenn der Fehler bleibt, kannst du über den Footer die Kontaktseite nutzen.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/button.html" import link_button %}
|
||||
{% block title %}Stundenfuchs{% endblock %}
|
||||
{% block page_class %}landing-page{% endblock %}
|
||||
{% block content %}
|
||||
<section class="landing-shell stack">
|
||||
<section class="landing-hero landing-hero--centered">
|
||||
<div class="landing-hero__logo-wrap" aria-hidden="true">
|
||||
<img class="landing-hero__logo" src="/img/fuchs.png" alt="" />
|
||||
</div>
|
||||
<div class="landing-hero__copy stack">
|
||||
<p class="landing-eyebrow">Arbeitszeit, Urlaub und Überstunden an einem Ort</p>
|
||||
<h1 class="landing-title">Stundenfuchs bringt Ordnung in deinen Arbeitsalltag.</h1>
|
||||
<p class="landing-lead">
|
||||
Dokumentiere deine Arbeitszeit übersichtlich, behalte Urlaub und Fehlzeiten im Blick und lass dir Sollstunden, Saldo und wichtige Auswertungen automatisch berechnen.
|
||||
</p>
|
||||
<div class="landing-cta-row">
|
||||
{{ link_button('Jetzt registrieren', '/register', extra_class='landing-cta-primary') }}
|
||||
{{ link_button('Einloggen', '/login', variant='ghost', extra_class='landing-cta-secondary') }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block page_class %}legal-page{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header(title, subtitle or '') }}
|
||||
{% call card('legal-card') %}
|
||||
<div class="legal-content">{{ content_html | safe }}</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button %}
|
||||
{% from "ui/form_field.html" import input_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}Anmeldung{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header("Anmeldung") }}
|
||||
{% call card('auth-card') %}
|
||||
<form action="/login" method="post" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
{{ input_field('E-Mail', 'email', type='email', required=true, autocomplete='username') }}
|
||||
{{ input_field('Passwort', 'password', type='password', required=true, autocomplete='current-password') }}
|
||||
{{ button('Einloggen', type='submit') }}
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<p>
|
||||
<a href="/verify-email/resend">Bestätigungslink erneut senden</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/password-reset/request">Passwort vergessen?</a>
|
||||
</p>
|
||||
<p>
|
||||
Noch kein Konto? <a href="/register">Jetzt registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button %}
|
||||
{% from "ui/form_field.html" import input_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}Zwei-Faktor-Anmeldung{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header("Zwei-Faktor-Anmeldung", "Methode: " ~ mfa_method_label) }}
|
||||
{% call card('auth-card') %}
|
||||
<form action="/login/mfa" method="post" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
{{ input_field('6-stelliger Code', 'code', type='text', required=true, attrs='inputmode="numeric" pattern="[0-9]{6}" minlength="6" maxlength="6"') }}
|
||||
{{ button('Code prüfen', type='submit') }}
|
||||
</form>
|
||||
{% if mfa_is_email %}
|
||||
<form action="/login/mfa/resend"
|
||||
method="post"
|
||||
class="stack mfa-resend-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
{{ button('Neuen Code senden', type='submit', variant='ghost') }}
|
||||
</form>
|
||||
{% endif %}
|
||||
<p>
|
||||
<a href="/login">Zurück zur Anmeldung</a>
|
||||
</p>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/day_row.html" import day_row with context %}
|
||||
{% from "ui/kpi_bar.html" import kpi_bar with context %}
|
||||
{% from "ui/month_header_bar.html" import month_header_bar with context %}
|
||||
{% from "ui/week_group_header.html" import week_group_header with context %}
|
||||
{% from "ui/week_group_card_mobile.html" import week_group_card_mobile with context %}
|
||||
{% from "ui/icon_button.html" import icon_link with context %}
|
||||
{% from "ui/warning_components.html" import workhours_target_warning_banner with context %}
|
||||
{% block title %}Monatsansicht{% endblock %}
|
||||
{% block page_class %}month-page{% endblock %}
|
||||
{% block content %}
|
||||
{% set month_names = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"] %}
|
||||
{% set return_to = request.url.path ~ ('?' ~ request.url.query if request.url.query else '') %}
|
||||
{% set month_prev_url = '/month?month=' ~ previous_month.strftime('%Y-%m') ~ '&view=' ~ view_mode %}
|
||||
{% set month_next_url = '/month?month=' ~ next_month.strftime('%Y-%m') ~ '&view=' ~ view_mode %}
|
||||
|
||||
<div class="month-view-shell">
|
||||
{% call month_header_bar(month_prev_url, month_next_url, month_names[month_start.month - 1] ~ ' ' ~ month_start.year) %}
|
||||
{{ icon_link('/entry/new?date=' ~ month_start.isoformat(), '/static/icons/add.svg', 'Tag hinzufügen') }}
|
||||
{{ icon_link('/bulk-entry?from=' ~ month_start.isoformat() ~ '&to=' ~ month_end.isoformat(), '/static/icons/batch.svg', 'Mehrfacheingabe') }}
|
||||
{{ icon_link('/export?from=' ~ month_start.isoformat() ~ '&to=' ~ month_end.isoformat(), '/static/icons/export.svg', 'Export') }}
|
||||
{% endcall %}
|
||||
|
||||
{{ kpi_bar([
|
||||
{'label': 'IST', 'value': ('%.2f'|format(month_ist / 60) )|replace('.00', '')},
|
||||
{'label': 'SOLL', 'value': ('%.2f'|format(month_soll / 60))|replace('.00', '')},
|
||||
{'label': 'DELTA', 'value': ('%.2f'|format(month_delta / 60))|replace('.00', ''), 'value_class': 'negative' if month_delta < 0 else 'positive'},
|
||||
{'label': 'KUMULIERT', 'value': ('%.2f'|format(header_cumulative_minutes / 60))|replace('.00', ''), 'value_class': 'negative' if header_cumulative_minutes < 0 else 'positive'}
|
||||
], 'kpi-bar--month') }}
|
||||
|
||||
{{ workhours_target_warning_banner(workhours_target_warning) }}
|
||||
|
||||
<section class="week-group-list">
|
||||
{% for week in weeks %}
|
||||
{% call week_group_card_mobile(week, csrf_token, return_to) %}
|
||||
{{ week_group_header(week, csrf_token, return_to) }}
|
||||
<div class="day-list day-list--month">
|
||||
{% for day in week.days %}
|
||||
{{ day_row(day, csrf_token, weekday_name_de(day.date) ~ ', ' ~ day.date.strftime('%d.%m.%Y'), return_to, 'month') }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button, link_button %}
|
||||
{% from "ui/form_field.html" import input_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header(title, selected_date.strftime('%d.%m.%Y')) }}
|
||||
{% call card('form-card') %}
|
||||
<div class="stack">
|
||||
{% if has_entry %}
|
||||
<p class="muted">An diesem Tag ist bereits regulaere Arbeitszeit eingetragen. Stundenausgleich ist dann nicht verfuegbar.</p>
|
||||
<div class="nav-row">
|
||||
{{ link_button('Zeiteintrag bearbeiten', '/entry/' ~ existing_entry_id ~ '/edit', 'primary') }}
|
||||
{{ link_button('Zurueck', return_to, 'ghost') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">
|
||||
Baut Ueberstunden direkt auf oder ab. Der Eintrag wirkt nur auf den Saldo, nicht auf Ist-Stunden oder den Arbeitsstunden-Counter.
|
||||
</p>
|
||||
{% if day_is_vacation %}
|
||||
<p class="muted">Fuer diesen Tag ist zusaetzlich Urlaub gesetzt.</p>
|
||||
{% elif day_special_status == 'holiday' %}
|
||||
<p class="muted">Fuer diesen Tag ist zusaetzlich Feiertag gesetzt.</p>
|
||||
{% elif day_special_status == 'sick' %}
|
||||
<p class="muted">Fuer diesen Tag ist zusaetzlich Krankheit gesetzt.</p>
|
||||
{% endif %}
|
||||
{% if overtime_adjustment_error %}
|
||||
<p class="flash flash--error">{{ overtime_adjustment_error }}</p>
|
||||
{% endif %}
|
||||
{% if day_overtime_adjustment_minutes %}
|
||||
<p class="muted">
|
||||
Aktuell gesetzt:
|
||||
<strong>{{ '+' if day_overtime_adjustment_minutes > 0 else '' }}{{ minutes_to_hhmm(day_overtime_adjustment_minutes) }}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<hr />
|
||||
|
||||
<form method="post" action="/overtime-adjustment/set" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<input type="hidden" name="adjustment_mode" value="manual" />
|
||||
<h2>Stunden</h2>
|
||||
{{ input_field('Manuell (+HH:MM oder -HH:MM)', 'adjustment_value', type='text', value='', attrs='placeholder=\"+02:30\"') }}
|
||||
<div class="nav-row">
|
||||
{{ button('Stunden speichern', type='submit') }}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
|
||||
<form method="post" action="/overtime-adjustment/set" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<input type="hidden" name="adjustment_mode" value="interval" />
|
||||
<h2>Von-Bis Uhrzeit</h2>
|
||||
<div class="inline-grid">
|
||||
{{ input_field('Von', 'interval_start_time', type='time', value='', required=true) }}
|
||||
{{ input_field('Bis', 'interval_end_time', type='time', value='', required=true) }}
|
||||
</div>
|
||||
<div class="nav-row">
|
||||
<button type="submit" name="interval_direction" value="positive" class="btn btn--primary">Als Plus speichern</button>
|
||||
<button type="submit" name="interval_direction" value="negative" class="btn btn--ghost">Als Minus speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
|
||||
<form method="post" action="/overtime-adjustment/set" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<input type="hidden" name="adjustment_mode" value="full_day" />
|
||||
<h2>Ganzer Tag</h2>
|
||||
<p class="muted">Der Tageswert wird aus Wochenstunden geteilt durch die relevanten Arbeitstage berechnet.</p>
|
||||
<div class="nav-row">
|
||||
<button type="submit" name="full_day_direction" value="positive" class="btn btn--primary">Ganzer Tag +</button>
|
||||
<button type="submit" name="full_day_direction" value="negative" class="btn btn--ghost">Ganzer Tag -</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="nav-row">
|
||||
{% if day_overtime_adjustment_minutes %}
|
||||
<form method="post" action="/overtime-adjustment/clear" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<button type="submit" class="btn btn--ghost">Stundenausgleich entfernen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{{ link_button('Zurueck', return_to, 'ghost') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button %}
|
||||
{% from "ui/form_field.html" import input_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}Neues Passwort setzen{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header("Neues Passwort setzen") }}
|
||||
{% call card('auth-card') %}
|
||||
{% if token %}
|
||||
<form action="/password-reset/confirm" method="post" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="token" value="{{ token }}" />
|
||||
{{ input_field('Neues Passwort (mindestens 10 Zeichen)', 'new_password', type='password', required=true, autocomplete='new-password', attrs='minlength="10"') }}
|
||||
{{ input_field('Neues Passwort wiederholen', 'new_password_repeat', type='password', required=true, autocomplete='new-password', attrs='minlength="10"') }}
|
||||
{{ button('Passwort speichern', type='submit') }}
|
||||
</form>
|
||||
{% endif %}
|
||||
<p>
|
||||
<a href="/login">Zur Anmeldung</a>
|
||||
</p>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/card.html" import card %}
|
||||
{% from "ui/button.html" import button %}
|
||||
{% from "ui/form_field.html" import input_field %}
|
||||
{% from "ui/page_header.html" import page_header %}
|
||||
{% block title %}Passwort zurücksetzen{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header('Passwort zurücksetzen', 'Gib deine E-Mail ein. Du erhältst einen Link zum Setzen eines neuen Passworts.') }}
|
||||
{% call card('auth-card') %}
|
||||
<form action="/password-reset/request" method="post" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
{{ input_field('E-Mail', 'email', type='email', required=true, autocomplete='username') }}
|
||||
{{ button('Reset-Link senden', type='submit') }}
|
||||
</form>
|
||||
<p>
|
||||
<a href="/login">Zur Anmeldung</a>
|
||||
</p>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,171 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/button.html" import button %}
|
||||
{% from "ui/form_field.html" import input_field %}
|
||||
{% block title %}Registrierung{% endblock %}
|
||||
{% block body_class %}register-theme{% endblock %}
|
||||
{% block page_class %}register-page{% endblock %}
|
||||
{% block content %}
|
||||
<section class="register-shell">
|
||||
<h1 class="register-title">Registrierung</h1>
|
||||
|
||||
<form action="/register" method="post" enctype="multipart/form-data" class="register-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<section class="register-section register-section-auth">
|
||||
{{ input_field('E-Mail', 'email', type='email', required=true, autocomplete='username') }}
|
||||
{{ input_field('Passwort (mindestens 10 Zeichen)', 'password', type='password', required=true, autocomplete='new-password', attrs='minlength="10"') }}
|
||||
<p class="muted register-email-note">Nach dem Anlegen bestätigst du deine E-Mail-Adresse über einen Link.</p>
|
||||
</section>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Backup importieren</h2>
|
||||
<p class="muted register-subtitle">Wenn du bereits eine Sicherung aus Stundenfuchs hast, kannst du sie direkt bei der Registrierung einspielen.</p>
|
||||
<label class="form-field">
|
||||
<span class="form-field__label">Backup-Datei (optional)</span>
|
||||
<input class="input" type="file" name="backup_file" accept=".json,application/json" />
|
||||
</label>
|
||||
<p class="muted register-subtitle">
|
||||
Deine E-Mail-Adresse, dein Passwort und deine gewählte Zwei-Faktor-Anmeldung bleiben erhalten.
|
||||
Arbeitsdaten und fachliche Einstellungen werden aus dem Backup übernommen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="muted register-section-note">
|
||||
Alle folgenden Einstellungen sind optional und können auch später in den Einstellungen geändert werden.
|
||||
</p>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Bundesland</h2>
|
||||
<p class="muted register-subtitle">für das automatische Festlegen von Feiertagen</p>
|
||||
<label>
|
||||
<select name="federal_state">
|
||||
<option value="">Bundesland auswählen...</option>
|
||||
{% for state in federal_state_options %}<option value="{{ state.code }}">{{ state.label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Urlaub</h2>
|
||||
<p class="muted register-subtitle">Wieviele Urlaubstage pro Jahr stehen dir zur Verfügung</p>
|
||||
{{ input_field('', 'vacation_days_total', type='number', placeholder='z. B. 30', attrs='min="0" max="365" step="1"') }}
|
||||
{{ input_field('Wochenstunden (Standard)', 'weekly_target_hours', type='number', value='25', attrs='min="0.25" step="0.25"') }}
|
||||
</section>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Erfassungsmodus</h2>
|
||||
<p class="muted register-subtitle">Lege fest, ob du deine Arbeitstage komplett selbst pflegst oder ob Stundenfuchs fehlende Arbeitstage bis heute automatisch ergänzt.</p>
|
||||
<label>
|
||||
<select name="entry_mode">
|
||||
<option value="manual" selected>Manuell</option>
|
||||
<option value="auto_until_today">Automatisch bis heute</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Überstunden</h2>
|
||||
<div class="register-grid-2">
|
||||
{{ input_field('Startdatum für Überstundenberechnung', 'overtime_start_date', type='date', value=today_iso) }}
|
||||
{{ input_field('Nach welchem Zeitraum verfallen Überstunden (Tage)', 'overtime_expiry_days', type='number', placeholder='optional', attrs='min="1" step="1"') }}
|
||||
</div>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="expire_negative_overtime" checked />
|
||||
<span>Negative Stunden verfallen auch</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Gesamtarbeitsstunden</h2>
|
||||
<p class="muted register-subtitle">z. B. für die Übersicht zu geleisteten Praxisstunden im Anerkennungsjahr der Erzieherausbildung.</p>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="workhours_counter_enabled" />
|
||||
<span>Gesamtarbeitsstunden Counter aktivieren</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Gesamtarbeitsstunden</h2>
|
||||
<p class="muted register-subtitle">z. B. für die Übersicht zu geleisteten Praxisstunden im Anerkennungsjahr der Erzieherausbildung.</p>
|
||||
<div class="register-grid-2 register-grid-counter">
|
||||
{{ input_field('Counter Startdatum', 'workhours_counter_start_date', type='date') }}
|
||||
{{ input_field('Counter Enddatum', 'workhours_counter_end_date', type='date') }}
|
||||
</div>
|
||||
{{ input_field('Bereits geleistete Zusatzstunden (optional)', 'workhours_counter_manual_offset_hours', type='number', placeholder='z. B. 80', attrs='min="0" step="0.25"') }}
|
||||
<p class="muted register-subtitle">Zusätzlich geleistete Stunden, z.B. aus Praktika</p>
|
||||
{{ input_field('Gesamtstundenziel (in Stunden)', 'workhours_counter_target_hours', type='number', placeholder='z. B. 1200', attrs='min="0.25" step="0.25"') }}
|
||||
<div class="register-checkbox-row">
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="workhours_counter_show_in_header" />
|
||||
<span>Counter im Header anzeigen</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="workhours_counter_target_email_enabled" />
|
||||
<span>Zielwarnung per E-Mail (wenn das Ziel im angegebenen Zeitraum nicht erreicht werden sollte aufgrund von z. B. Krankheit)</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Relevante Arbeitstage</h2>
|
||||
<p class="muted register-subtitle">
|
||||
Diese Tage steuern Soll und Urlaubslogik. Beispiel: Wenn du nur Montag bis Donnerstag arbeitest, werden Sollstunden auf diese vier Tage verteilt.
|
||||
</p>
|
||||
<fieldset class="weekday-fieldset register-weekday-fieldset">
|
||||
<div class="register-weekday-grid">
|
||||
{% for weekday in weekday_options %}
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="working_days"
|
||||
value="{{ weekday.value }}"
|
||||
{% if weekday.value < 5 %}checked{% endif %} />
|
||||
<span>{{ weekday.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="register-checkbox-row">
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="count_vacation_as_worktime" />
|
||||
<span>Urlaubstage wie reguläre Arbeitstage rechnen</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="count_holiday_as_worktime" />
|
||||
<span>Feiertage wie reguläre Arbeitstage rechnen</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="count_sick_as_worktime" />
|
||||
<span>Kranktage wie reguläre Arbeitstage rechnen</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="automatic_break_rules_enabled" />
|
||||
<span>Gesetzliche Pausen automatisch nach deutscher Arbeitszeit berechnen</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="register-section">
|
||||
<h2>Zwei-Faktor-Anmeldung</h2>
|
||||
<p class="muted register-subtitle">Zur Erhöhung der Sicherheit</p>
|
||||
<label>
|
||||
<select name="mfa_preference">
|
||||
<option value="none" selected>Keine 2FA</option>
|
||||
<option value="totp">Authenticator-App (6-stelliger Code)</option>
|
||||
<option value="email">E-Mail-Code</option>
|
||||
</select>
|
||||
</label>
|
||||
{% if not email_mfa_available %}
|
||||
<p class="muted">Hinweis: E-Mail-2FA ist aktuell nicht verfügbar, da kein Mailserver konfiguriert ist.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="register-actions">
|
||||
{{ button('Konto anlegen', type='submit', extra_class='register-submit') }}
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<p class="register-footer">
|
||||
Du hast bereits ein Konto? <a href="/login">Zur Anmeldung</a>
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,812 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "ui/segmented_toggle.html" import segmented_toggle %}
|
||||
{% from "ui/collapsible_section.html" import collapsible_section %}
|
||||
{% block title %}Einstellungen{% endblock %}
|
||||
{% block body_class %}settings-theme{% endblock %}
|
||||
{% block page_class %}settings-page{% endblock %}
|
||||
{% block content %}
|
||||
<section class="top-row">
|
||||
<h1>Einstellungen</h1>
|
||||
</section>
|
||||
{% if is_admin %}
|
||||
{{ segmented_toggle([
|
||||
{'href': '/settings?tab=settings', 'label': 'Einstellungen', 'active': active_settings_tab != 'admin'},
|
||||
{'href': '/settings?tab=admin', 'label': 'Admin', 'active': active_settings_tab == 'admin'}
|
||||
], 'Einstellungsbereiche', 'settings-tabs') }}
|
||||
{% endif %}
|
||||
<section class="settings-grid">
|
||||
{% if not is_admin or active_settings_tab != 'admin' %}
|
||||
{% call collapsible_section('Urlaub', 'settings-vacation') %}
|
||||
<p class="muted">Lege hier deine Gesamturlaubstage pro Kalenderjahr fest. Im Header siehst du danach Resturlaub/Gesamturlaub.</p>
|
||||
<form method="post" action="/settings/vacation-allowance" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="vacation_show_in_header_present" value="1" />
|
||||
<div class="inline-grid">
|
||||
<label>
|
||||
Gesamturlaubstage pro Jahr
|
||||
<input type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
step="1"
|
||||
name="vacation_days_total"
|
||||
value="{{ user.vacation_days_total }}"
|
||||
required />
|
||||
</label>
|
||||
<label>
|
||||
Aktueller Stand ({{ header_vacation_year }})
|
||||
<input type="text"
|
||||
value="{{ header_vacation_days_remaining }} / {{ header_vacation_days_total }} (verplant/genutzt: {{ header_vacation_days_used }})"
|
||||
disabled />
|
||||
</label>
|
||||
</div>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="vacation_show_in_header"
|
||||
{% if user.vacation_show_in_header %}checked{% endif %} />
|
||||
<span>Resturlaub im Header anzeigen</span>
|
||||
</label>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
<p class="muted">Definierte Urlaubstage reduzieren automatisch das Wochen-Soll für die betroffenen Wochen.</p>
|
||||
<form method="post" action="/settings/vacations/add" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="inline-grid">
|
||||
<label>
|
||||
Startdatum
|
||||
<input type="date" name="start_date" value="{{ vacation_start }}" required />
|
||||
</label>
|
||||
<label>
|
||||
Enddatum
|
||||
<input type="date" name="end_date" value="{{ vacation_end }}" required />
|
||||
</label>
|
||||
</div>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="include_weekends" />
|
||||
<span>Wochenenden mit einschließen</span>
|
||||
</label>
|
||||
<label>
|
||||
Notiz (optional)
|
||||
<input type="text" name="notes" />
|
||||
</label>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
<div class="vacation-list">
|
||||
{% for vacation in vacation_ranges %}
|
||||
<article class="vacation-item">
|
||||
<div>
|
||||
<strong>{{ vacation.start_date.strftime("%d.%m.%Y") }} - {{ vacation.end_date.strftime("%d.%m.%Y") }}</strong>
|
||||
<p class="muted">Effektive Urlaubstage unter Berücksichtigung deiner Arbeitstage.</p>
|
||||
</div>
|
||||
<form method="post"
|
||||
action="/settings/vacations/delete-range"
|
||||
class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden"
|
||||
name="start_date"
|
||||
value="{{ vacation.start_date.isoformat() }}" />
|
||||
<input type="hidden"
|
||||
name="end_date"
|
||||
value="{{ vacation.end_date.isoformat() }}" />
|
||||
<button type="submit" class="button danger">Löschen</button>
|
||||
</form>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="muted">Noch keine Urlaubszeiträume angelegt.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Wochenstunden', 'settings-weekly-target') %}
|
||||
<p class="muted">Lege fest, wie viele Stunden du generell pro Woche arbeiten möchtest (Standard-Soll).</p>
|
||||
<form method="post" action="/settings/weekly-target" class="stack" data-component="break-settings-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
Wochenstunden
|
||||
<input type="number"
|
||||
min="0.25"
|
||||
step="0.25"
|
||||
name="weekly_target_hours"
|
||||
value="{{ '%.2f'|format(settings_weekly_target_minutes / 60) |replace('.00', '') }}"
|
||||
required />
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="automatic_break_rules_enabled"
|
||||
data-break-settings-toggle
|
||||
{% if user.automatic_break_rules_enabled %}checked{% endif %} />
|
||||
<span>Gesetzliche Pausen automatisch nach deutscher Arbeitszeit berechnen</span>
|
||||
</label>
|
||||
<label>
|
||||
Tägliche Pause in Minuten
|
||||
<input type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
name="default_break_minutes"
|
||||
value="{{ user.default_break_minutes }}"
|
||||
data-break-settings-minutes
|
||||
{% if user.automatic_break_rules_enabled %}disabled{% endif %} />
|
||||
</label>
|
||||
<p class="muted">
|
||||
Dieser Wert wird für neue reguläre Arbeitszeiteinträge und automatische Einträge verwendet, solange die gesetzliche Pausenregel nicht aktiv ist.
|
||||
</p>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Standardansicht', 'settings-preferences') %}
|
||||
<form method="post" action="/settings/preferences" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="preferred_month_view_mode" value="flat" />
|
||||
<label>
|
||||
Startansicht nach Anmeldung
|
||||
<select name="preferred_home_view" required>
|
||||
<option value="week"
|
||||
{% if user.preferred_home_view == 'week' %}selected{% endif %}>Wochenansicht</option>
|
||||
<option value="month"
|
||||
{% if user.preferred_home_view == 'month' %}selected{% endif %}>Monatsansicht</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Erfassungsmodus
|
||||
<select name="entry_mode" required>
|
||||
<option value="manual"
|
||||
{% if user.entry_mode == 'manual' %}selected{% endif %}>Manuell (jeden Tag selbst erfassen)</option>
|
||||
<option value="auto_until_today"
|
||||
{% if user.entry_mode == 'auto_until_today' %}selected{% endif %}>Automatisch bis heute</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">
|
||||
Im automatischen Modus werden fehlende Einträge für deine Arbeitstage bis einschließlich heute automatisch angelegt. Abweichungen kannst du danach einzeln anpassen.
|
||||
</p>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Überstunden-Regeln', 'settings-overtime') %}
|
||||
<p class="muted">Optionales Startdatum und Verfall für die kumulierte Überstunden-Berechnung.</p>
|
||||
<form method="post" action="/settings/overtime" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="inline-grid">
|
||||
<label>
|
||||
Startdatum (optional)
|
||||
<input type="date"
|
||||
name="overtime_start_date"
|
||||
value="{{ user.overtime_start_date.isoformat() if user.overtime_start_date else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Verfall in Tagen (optional)
|
||||
<input type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
name="overtime_expiry_days"
|
||||
value="{{ user.overtime_expiry_days if user.overtime_expiry_days is not none else '' }}" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="expire_negative_overtime"
|
||||
{% if user.expire_negative_overtime %}checked{% endif %} />
|
||||
<span>Negative Stunden verfallen ebenfalls</span>
|
||||
</label>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
<div class="vacation-list">
|
||||
<article class="vacation-item">
|
||||
<div>
|
||||
<strong>Saldoaufbau gesamt</strong>
|
||||
<p class="muted">{{ minutes_to_hhmm(overtime_adjustment_total_positive) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Saldoabbau gesamt</strong>
|
||||
<p class="muted">{{ minutes_to_hhmm(overtime_adjustment_total_negative) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Ganze Tage</strong>
|
||||
<p class="muted">{{ overtime_adjustment_full_day_count }}</p>
|
||||
</div>
|
||||
</article>
|
||||
{% for adjustment in overtime_adjustments %}
|
||||
<article class="vacation-item">
|
||||
<div>
|
||||
<strong>{{ adjustment.date.strftime("%d.%m.%Y") }}</strong>
|
||||
<p class="muted">{{ adjustment.notes or "Stundenausgleich" }}</p>
|
||||
</div>
|
||||
<div class="settings-adjustment-meta">
|
||||
<strong class="{% if adjustment.minutes < 0 %}negative{% else %}positive{% endif %}">
|
||||
{{ '+' if adjustment.minutes > 0 else '' }}{{ minutes_to_hhmm(adjustment.minutes) }}
|
||||
</strong>
|
||||
<a href="/overtime-adjustment/edit?date={{ adjustment.date.isoformat() }}" class="button ghost">Bearbeiten</a>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="muted">Noch keine Ausgleichsstunden eingetragen.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Arbeitsstunden-Counter', 'settings-workhours-counter') %}
|
||||
<div class="settings-workhours-intro stack">
|
||||
<p class="muted">Hier kannst du einen Zeitraum festlegen und sehen, wie viele Stunden du darin insgesamt gesammelt hast. Das ist zum Beispiel hilfreich für Praxisstunden im Anerkennungsjahr.</p>
|
||||
<p class="muted">Urlaub, Feiertage und Krankheit werden nur dann mitgezählt, wenn du das unter „Relevante Arbeitstage“ aktiviert hast.</p>
|
||||
</div>
|
||||
<form method="post" action="/settings/workhours-counter" class="settings-workhours-form stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="workhours_counter_enabled"
|
||||
{% if user.workhours_counter_enabled %}checked{% endif %} />
|
||||
<span>Arbeitsstunden-Counter aktivieren</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="workhours_counter_show_in_header"
|
||||
{% if user.workhours_counter_show_in_header %}checked{% endif %} />
|
||||
<span>Counter im Header anzeigen</span>
|
||||
</label>
|
||||
<div class="inline-grid">
|
||||
<label>
|
||||
Zeitraum von
|
||||
<input type="date"
|
||||
name="workhours_counter_start_date"
|
||||
value="{{ user.workhours_counter_start_date.isoformat() if user.workhours_counter_start_date else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Zeitraum bis
|
||||
<input type="date"
|
||||
name="workhours_counter_end_date"
|
||||
value="{{ user.workhours_counter_end_date.isoformat() if user.workhours_counter_end_date else '' }}" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-grid settings-workhours-inline-grid">
|
||||
<label class="settings-workhours-field">
|
||||
Zusätzliche Stunden (optional)
|
||||
<input type="number"
|
||||
min="0"
|
||||
step="0.25"
|
||||
name="workhours_counter_manual_offset_hours"
|
||||
value="{{ '%.2f'|format(user.workhours_counter_manual_offset_minutes / 60) |replace('.00', '') if user.workhours_counter_manual_offset_minutes else '' }}" />
|
||||
</label>
|
||||
<label class="settings-workhours-field">
|
||||
Zielstunden im Zeitraum (optional)
|
||||
<input type="number"
|
||||
min="0.25"
|
||||
step="0.25"
|
||||
name="workhours_counter_target_hours"
|
||||
value="{{ '%.2f'|format(user.workhours_counter_target_minutes / 60) |replace('.00', '') if user.workhours_counter_target_minutes is not none else '' }}" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted settings-workhours-field-hint">Zum Beispiel bereits geleistete Praxis- oder Praktikumsstunden, die nicht im Tracker erfasst wurden.</p>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="workhours_counter_target_email_enabled"
|
||||
{% if not mail_settings_available %}disabled{% endif %}
|
||||
{% if user.workhours_counter_target_email_enabled %}checked{% endif %} />
|
||||
<span>E-Mail senden, wenn das Ziel voraussichtlich nicht erreicht wird</span>
|
||||
</label>
|
||||
{% if not mail_settings_available %}
|
||||
<p class="muted">Diese Funktion ist erst verfügbar, wenn ein E-Mail-Server eingerichtet wurde.</p>
|
||||
{% endif %}
|
||||
<p class="muted">Beispiel: So kannst du deine Praxisstunden im Anerkennungsjahr im Blick behalten.</p>
|
||||
{% if user.workhours_counter_enabled %}
|
||||
<p class="muted">
|
||||
{% if workhours_counter_minutes is not none %}
|
||||
Aktueller Stand im gewählten Zeitraum:
|
||||
{% else %}
|
||||
Bitte gültigen Zeitraum setzen, um den Counter zu berechnen.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if workhours_counter_warning %}
|
||||
<div class="settings-counter-badges app-total-badges" aria-label="Ziel und Prognose">
|
||||
<span class="app-total-badge app-total-badge-workhours">
|
||||
<span class="app-total-badge__label">Bisher</span>
|
||||
<span class="app-total-badge__value">
|
||||
{% if workhours_counter_minutes is not none %}
|
||||
{{ minutes_to_hhmm(workhours_counter_minutes) }}
|
||||
{% else %}
|
||||
--
|
||||
{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
<span class="app-total-badge app-total-badge-target">
|
||||
<span class="app-total-badge__label">Ziel</span>
|
||||
<span class="app-total-badge__value">{{ minutes_to_hhmm(workhours_counter_warning.target_minutes) }}</span>
|
||||
</span>
|
||||
<span class="app-total-badge app-total-badge-projection {% if workhours_counter_warning.at_risk %}is-negative{% else %}is-positive{% endif %}">
|
||||
<span class="app-total-badge__label">Prognose</span>
|
||||
<span class="app-total-badge__value">{{ minutes_to_hhmm(workhours_counter_warning.projected_minutes) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% if workhours_counter_warning.at_risk %}
|
||||
<p class="muted">
|
||||
Bis zum Ziel fehlen voraussichtlich noch <strong class="negative">{{ minutes_to_hhmm(workhours_counter_warning.missing_minutes) }}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Relevante Arbeitstage', 'settings-workdays') %}
|
||||
<p class="muted">Diese Tage werden für Soll-/Delta-Berechnung verwendet (z. B. 4-Tage-Woche Mo-Do).</p>
|
||||
<form method="post" action="/settings/workdays" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<fieldset class="weekday-fieldset">
|
||||
<legend>Arbeitstage</legend>
|
||||
<div class="weekday-grid">
|
||||
{% for weekday in weekday_options %}
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="working_days"
|
||||
value="{{ weekday.value }}"
|
||||
{% if weekday.value in working_days_selected %}checked{% endif %} />
|
||||
<span>{{ weekday.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="stack">
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="count_vacation_as_worktime"
|
||||
{% if user.count_vacation_as_worktime %}checked{% endif %} />
|
||||
<span>Urlaubstage wie reguläre Arbeitstage rechnen</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="count_holiday_as_worktime"
|
||||
{% if user.count_holiday_as_worktime %}checked{% endif %} />
|
||||
<span>Feiertage wie reguläre Arbeitstage rechnen</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="count_sick_as_worktime"
|
||||
{% if user.count_sick_as_worktime %}checked{% endif %} />
|
||||
<span>Kranktage wie reguläre Arbeitstage rechnen</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Sicherheit (2FA)', 'settings-mfa') %}
|
||||
{% set mfa_totp_pending = mfa_setup_secret and user.mfa_method == 'none' %}
|
||||
{% set mfa_selected_method = 'totp' if mfa_totp_pending else user.mfa_method %}
|
||||
<p class="muted">
|
||||
Status:
|
||||
<strong>
|
||||
{% if mfa_totp_pending %}
|
||||
TOTP-Einrichtung läuft
|
||||
{% else %}
|
||||
{{ mfa_method_labels.get(user.mfa_method, 'Unbekannt') }}
|
||||
{% endif %}
|
||||
</strong>
|
||||
</p>
|
||||
{% if mfa_totp_pending %}
|
||||
<p class="muted">2FA wird aktiviert, sobald du den aktuellen 6-stelligen Code aus deiner Authenticator-App bestätigst.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/settings/mfa" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
Zwei-Faktor-Methode
|
||||
<select name="mfa_method" required>
|
||||
<option value="none" {% if mfa_selected_method == 'none' %}selected{% endif %}>Keine 2FA</option>
|
||||
<option value="totp" {% if mfa_selected_method == 'totp' %}selected{% endif %}>Authenticator-App (TOTP)</option>
|
||||
<option value="email" {% if mfa_selected_method == 'email' %}selected{% endif %}>E-Mail-Code</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Aktuelles Passwort bestätigen
|
||||
<input type="password" name="current_password" required />
|
||||
</label>
|
||||
<label>
|
||||
Setup-Code (nur für TOTP-Aktivierung)
|
||||
<input type="text"
|
||||
name="setup_code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6" />
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="regenerate_totp" />
|
||||
<span>TOTP neu einrichten (alten Schlüssel verwerfen)</span>
|
||||
</label>
|
||||
{% if mfa_setup_secret %}
|
||||
<div class="settings-note">
|
||||
<p class="muted">
|
||||
<strong>TOTP-Setup aktiv:</strong> Hinterlege den folgenden Schlüssel oder die URI in deiner Authenticator-App und bestätige danach den Code.
|
||||
</p>
|
||||
<label>
|
||||
TOTP Secret
|
||||
<input type="text" value="{{ mfa_setup_secret }}" readonly />
|
||||
</label>
|
||||
<label>
|
||||
TOTP URI
|
||||
<input type="text" value="{{ mfa_setup_uri }}" readonly />
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
<div class="settings-auth-row">
|
||||
{% call collapsible_section('Account', 'settings-account', 'settings-auth-card', 'account-security') %}
|
||||
<form method="post" action="/settings/profile" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
E-Mail-Adresse
|
||||
<input type="email" name="email" value="{{ user.email }}" required />
|
||||
</label>
|
||||
<label>
|
||||
Bundesland
|
||||
<select name="federal_state">
|
||||
<option value="">Bitte auswählen</option>
|
||||
{% for state in federal_state_options %}
|
||||
<option value="{{ state.code }}"
|
||||
{% if user.federal_state == state.code %}selected{% endif %}>{{ state.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">
|
||||
Gesetzliche Feiertage deines Bundeslands werden automatisch als Feiertag markiert, sofern an diesen Tagen keine Arbeitszeit eingetragen ist.
|
||||
</p>
|
||||
<label>
|
||||
Aktuelles Passwort bestätigen
|
||||
<input type="password" name="current_password" required />
|
||||
</label>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Passwort ändern', 'settings-password', 'settings-auth-card', 'account-security') %}
|
||||
<form method="post" action="/settings/password" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
Aktuelles Passwort
|
||||
<input type="password" name="current_password" required />
|
||||
</label>
|
||||
<label>
|
||||
Neues Passwort
|
||||
<input type="password" name="new_password" minlength="10" required />
|
||||
</label>
|
||||
<label>
|
||||
Neues Passwort wiederholen
|
||||
<input type="password" name="new_password_repeat" minlength="10" required />
|
||||
</label>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% call collapsible_section('Datenexport', 'settings-export') %}
|
||||
<p class="muted">Lade hier alle bisher eingetragenen Daten herunter. Für Excel und PDF wird dein kompletter erfasster Zeitraum exportiert. Die Backup-Datei ist für Sicherung und späteren Import gedacht.</p>
|
||||
<form method="post" action="/settings/export-all" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="settings-export-actions">
|
||||
<button type="submit" name="format" value="xlsx" class="button">Alles als Excel exportieren</button>
|
||||
<button type="submit" name="format" value="pdf" class="button ghost">Alles als PDF exportieren</button>
|
||||
<button type="submit" name="format" value="backup_json" class="button ghost">Backup-Datei herunterladen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="muted">Die Backup-Datei enthält deine Einstellungen, Arbeitszeiteinträge, Urlaub, Sondertage, Soll-Historie und Stundenausgleich in einem strukturierten Format. Sicherheits- und Kontodaten sind nicht enthalten.</p>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Backup importieren', 'settings-import') %}
|
||||
<p class="muted">Du kannst eine zuvor exportierte Backup-Datei wieder einspielen. Dein Konto, dein Passwort und deine Sicherheitsdaten bleiben dabei unverändert.</p>
|
||||
<form method="post" action="/settings/import/preview" enctype="multipart/form-data" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
Importmodus
|
||||
<select name="import_mode">
|
||||
<option value="merge" {% if import_mode_selected == 'merge' %}selected{% endif %}>Zusammenführen</option>
|
||||
<option value="replace_user_data"
|
||||
{% if import_mode_selected == 'replace_user_data' %}selected{% endif %}>Alle bisherigen Daten ersetzen</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">
|
||||
Zusammenführen behält bestehende Tagesdaten bei und ergänzt nur konfliktfreie Inhalte. Ersetzen löscht zuerst alle importierbaren Arbeits- und Einstellungsdaten deines Kontos.
|
||||
</p>
|
||||
<label>
|
||||
Backup-Datei
|
||||
<input type="file" name="backup_file" accept=".json,application/json" required />
|
||||
</label>
|
||||
<button type="submit" class="button">Backup prüfen</button>
|
||||
</form>
|
||||
{% if import_preview %}
|
||||
<div class="settings-import-preview stack">
|
||||
<div class="settings-import-preview__header">
|
||||
<div>
|
||||
<h3>Importvorschau</h3>
|
||||
<p class="muted">
|
||||
Backup v{{ import_preview.backup_version }}
|
||||
{% if import_preview.source_app_version %}• exportiert mit {{ import_preview.source_app_version }}{% endif %}
|
||||
{% if import_preview.exported_at %}• {{ import_preview.exported_at }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<span class="settings-import-preview__mode">{{ import_preview.mode_label }}</span>
|
||||
</div>
|
||||
<div class="settings-import-grid">
|
||||
<div class="settings-import-stat">
|
||||
<strong>{{ import_preview.counts.time_entries }}</strong>
|
||||
<span>Arbeitszeiteinträge</span>
|
||||
</div>
|
||||
<div class="settings-import-stat">
|
||||
<strong>{{ import_preview.counts.weekly_target_rules }}</strong>
|
||||
<span>Wochenziele</span>
|
||||
</div>
|
||||
<div class="settings-import-stat">
|
||||
<strong>{{ import_preview.counts.vacation_periods }}</strong>
|
||||
<span>Urlaubszeiträume</span>
|
||||
</div>
|
||||
<div class="settings-import-stat">
|
||||
<strong>{{ import_preview.counts.special_day_statuses }}</strong>
|
||||
<span>Sondertage</span>
|
||||
</div>
|
||||
<div class="settings-import-stat">
|
||||
<strong>{{ import_preview.counts.overtime_adjustments }}</strong>
|
||||
<span>Stundenausgleich</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-import-summary">
|
||||
<p class="muted">
|
||||
Übernommen werden auch fachliche Einstellungen wie Wochenstunden, relevante Arbeitstage, Überstunden-Regeln, Arbeitsstunden-Counter und das Bundesland.
|
||||
</p>
|
||||
<ul class="settings-import-conflicts">
|
||||
<li>Konflikte Arbeitszeiteinträge: {{ import_preview.conflicts.time_entries }}</li>
|
||||
<li>Konflikte Wochenziele: {{ import_preview.conflicts.weekly_target_rules }}</li>
|
||||
<li>Konflikte Urlaubszeiträume: {{ import_preview.conflicts.vacation_periods }}</li>
|
||||
<li>Konflikte Sondertage: {{ import_preview.conflicts.special_day_statuses }}</li>
|
||||
<li>Konflikte Stundenausgleich: {{ import_preview.conflicts.overtime_adjustments }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<form method="post" action="/settings/import/execute" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="preview_id" value="{{ import_preview.id }}" />
|
||||
{% if import_preview.mode == 'replace_user_data' %}
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="confirm_replace" />
|
||||
<span>Ich möchte meine bisherigen importierbaren Daten wirklich vollständig durch dieses Backup ersetzen.</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
<button type="submit" class="button">Import jetzt ausführen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Konto löschen', 'settings-delete-account', 'danger-card') %}
|
||||
<p class="muted">Wenn du dein Konto löschst, werden alle zugehörigen Daten dauerhaft entfernt: Arbeitszeiten, Urlaub, Sondertage, Stundenausgleich, Soll-Historie und persönliche Einstellungen.</p>
|
||||
<form method="post" action="/settings/account/delete" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
Zur Bestätigung deine E-Mail-Adresse eingeben
|
||||
<input type="email" name="confirm_email" placeholder="{{ user.email }}" required />
|
||||
</label>
|
||||
<label>
|
||||
Aktuelles Passwort bestätigen
|
||||
<input type="password" name="current_password" required />
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="confirm_delete" />
|
||||
<span>Ich möchte mein Konto und alle zugehörigen Daten dauerhaft löschen.</span>
|
||||
</label>
|
||||
<button type="submit" class="button danger">Konto dauerhaft löschen</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% if is_admin and active_settings_tab == 'admin' %}
|
||||
{% call collapsible_section('Benutzerverwaltung', 'settings-admin-users', 'admin-card') %}
|
||||
<p class="muted">Aktive Admins: {{ admin_user_count }}</p>
|
||||
<div class="admin-user-list">
|
||||
{% for managed in managed_users %}
|
||||
{% set disable_delete = managed.id == user.id or (managed.role == 'admin' and managed.is_active and admin_user_count <= 1) %}
|
||||
<form method="post"
|
||||
action="/settings/admin/users/{{ managed.id }}"
|
||||
class="admin-user-row">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="admin-user-meta">
|
||||
<strong>{{ managed.email }}</strong>
|
||||
<span class="muted">erstellt: {{ managed.created_at.strftime("%d.%m.%Y") }}</span>
|
||||
</div>
|
||||
<label>
|
||||
Rolle
|
||||
<select name="role" required>
|
||||
<option value="user" {% if managed.role == 'user' %}selected{% endif %}>User</option>
|
||||
<option value="admin" {% if managed.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="is_active"
|
||||
{% if managed.is_active %}checked{% endif %} />
|
||||
<span>Aktiv</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="reset_mfa" />
|
||||
<span>MFA zurücksetzen</span>
|
||||
</label>
|
||||
<div class="admin-user-actions">
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
<button type="submit"
|
||||
formaction="/settings/admin/users/{{ managed.id }}/delete"
|
||||
formmethod="post"
|
||||
class="button danger"
|
||||
{% if disable_delete %}disabled{% endif %}
|
||||
onclick="return confirm('Benutzer wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.');">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('E-Mail-Server', 'settings-admin-email', 'admin-card') %}
|
||||
<p class="muted">Wird für Passwort-Reset, E-Mail-MFA und Registrierungsmails verwendet.</p>
|
||||
<form method="post" action="/settings/admin/email-server" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="inline-grid">
|
||||
<label>
|
||||
SMTP Host
|
||||
<input type="text"
|
||||
name="smtp_host"
|
||||
value="{{ email_server.smtp_host }}"
|
||||
required />
|
||||
</label>
|
||||
<label>
|
||||
SMTP Port
|
||||
<input type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
name="smtp_port"
|
||||
value="{{ email_server.smtp_port }}"
|
||||
required />
|
||||
</label>
|
||||
<label>
|
||||
SMTP Username
|
||||
<input type="text"
|
||||
name="smtp_username"
|
||||
value="{{ email_server.smtp_username }}" />
|
||||
</label>
|
||||
<label>
|
||||
SMTP Passwort
|
||||
{% if email_server.has_password %}(leer lassen = unverändert){% endif %}
|
||||
<input type="password" name="smtp_password" />
|
||||
</label>
|
||||
<label>
|
||||
Absender E-Mail
|
||||
<input type="email"
|
||||
name="from_email"
|
||||
value="{{ email_server.from_email }}"
|
||||
required />
|
||||
</label>
|
||||
<label>
|
||||
Absender Name
|
||||
<input type="text"
|
||||
name="from_name"
|
||||
value="{{ email_server.from_name }}"
|
||||
required />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-grid">
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="use_starttls"
|
||||
{% if email_server.use_starttls %}checked{% endif %} />
|
||||
<span>STARTTLS verwenden</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="use_ssl"
|
||||
{% if email_server.use_ssl %}checked{% endif %} />
|
||||
<span>SMTP SSL verwenden</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="verify_tls"
|
||||
{% if email_server.verify_tls %}checked{% endif %} />
|
||||
<span>TLS Zertifikat prüfen</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="registration_mails_enabled"
|
||||
{% if email_server.registration_mails_enabled %}checked{% endif %} />
|
||||
<span>Registrierungsmails aktiv</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="password_reset_mails_enabled"
|
||||
{% if email_server.password_reset_mails_enabled %}checked{% endif %} />
|
||||
<span>Passwort-Reset-Mails aktiv</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="registration_admin_notify_enabled"
|
||||
{% if email_server.registration_admin_notify_enabled %}checked{% endif %} />
|
||||
<span>Infomails bei neuer Registrierung aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-recipient-picker">
|
||||
<p class="muted">Empfänger für Registrierungs-Infomails (aktive Admins)</p>
|
||||
{% if admin_recipients %}
|
||||
<div class="admin-recipient-grid">
|
||||
{% for admin_recipient in admin_recipients %}
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox"
|
||||
name="registration_admin_notify_admin_ids"
|
||||
value="{{ admin_recipient.id }}"
|
||||
{% if admin_recipient.id in email_server.registration_admin_notify_admin_ids %}checked{% endif %} />
|
||||
<span>{{ admin_recipient.email }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Keine aktiven Admins gefunden.</p>
|
||||
{% endif %}
|
||||
{% if email_server.registration_notify_fallback_email %}
|
||||
<p class="muted">
|
||||
Wenn keine Admins ausgewählt sind, wird die Fallback-Adresse
|
||||
<strong>{{ email_server.registration_notify_fallback_email }}</strong> genutzt.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
<form method="post"
|
||||
action="/settings/admin/email-server/test"
|
||||
class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit" class="button ghost">Testmail an mich senden</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Rechtliches', 'settings-admin-legal', 'admin-card') %}
|
||||
<p class="muted">Diese Inhalte werden öffentlich über den Footer unter Impressum und Datenschutz angezeigt. Markdown ist erlaubt und wird beim Anzeigen sicher bereinigt.</p>
|
||||
<form method="post" action="/settings/admin/site-content" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>
|
||||
Impressum (Markdown)
|
||||
<textarea name="impressum_markdown" rows="14" required>{{ site_content_markdown['impressum'] }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Datenschutz (Markdown)
|
||||
<textarea name="privacy_markdown" rows="16" required>{{ site_content_markdown['datenschutz'] }}</textarea>
|
||||
</label>
|
||||
<button type="submit" class="button">Speichern</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% call collapsible_section('Kontakt & Tickets', 'settings-admin-tickets', 'admin-card') %}
|
||||
<p class="muted">Neue Nachrichten aus dem Kontaktformular werden hier als Tickets gesammelt. Für Benachrichtigungen werden dieselben Admin-Empfänger wie bei Registrierungs-Infomails verwendet.</p>
|
||||
<div class="support-ticket-list">
|
||||
{% for ticket in support_tickets %}
|
||||
<article class="support-ticket-card">
|
||||
<div class="support-ticket-card__header">
|
||||
<div class="support-ticket-card__title-wrap">
|
||||
<h3>{{ ticket.subject }}</h3>
|
||||
<p class="muted">
|
||||
{{ ticket_category_label(ticket.category) }} · {{ ticket_status_label(ticket.status) }} · {{ ticket.created_at.strftime("%d.%m.%Y %H:%M") }} UTC
|
||||
</p>
|
||||
</div>
|
||||
<div class="support-ticket-card__meta">
|
||||
<strong>{{ ticket.email }}</strong>
|
||||
<span class="muted">{{ ticket.name or 'Ohne Namen' }}</span>
|
||||
{% if ticket.user_id %}<span class="muted">Angemeldeter Nutzer</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="support-ticket-card__message">
|
||||
{{ ticket.message }}
|
||||
</div>
|
||||
<form method="post" action="/settings/admin/tickets/{{ ticket.id }}" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="inline-grid support-ticket-card__controls">
|
||||
<label>
|
||||
Status
|
||||
<select name="status" required>
|
||||
<option value="open" {% if ticket.status == 'open' %}selected{% endif %}>Offen</option>
|
||||
<option value="closed" {% if ticket.status == 'closed' %}selected{% endif %}>Geschlossen</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Interne Notiz
|
||||
<textarea name="admin_notes" rows="4">{{ ticket.admin_notes or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="button">Ticket speichern</button>
|
||||
</form>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="muted">Aktuell liegen keine Kontakt-Tickets vor.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% from "ui/flash.html" import alert %}
|
||||
{% set error_message = error if error is defined else None %}
|
||||
{% if success_message %}{{ alert(success_message, "success") }}{% endif %}
|
||||
{% if error_message %}{{ alert(error_message, "error") }}{% endif %}
|
||||
{% if info_message %}{{ alert(info_message, "info") }}{% endif %}
|
||||
{% if flash_messages %}
|
||||
{% for flash in flash_messages %}{{ alert(flash.message, flash.level or "info") }}{% endfor %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,10 @@
|
||||
<footer class="site-footer">
|
||||
<div class="container site-footer-inner">
|
||||
<small class="muted">{{ app_name }}</small>
|
||||
<nav class="site-footer-nav" aria-label="Footer">
|
||||
<a href="/kontakt">Kontakt</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/datenschutz">Datenschutz</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,38 @@
|
||||
{% set is_auth_header = (not user) and (request.url.path.startswith('/login') or request.url.path.startswith('/register')) %}
|
||||
<header class="site-header app-topbar {% if is_auth_header %}is-auth-header{% endif %}">
|
||||
<div class="container app-topbar-inner {% if user %}is-user{% else %}is-guest{% endif %}">
|
||||
<div class="app-brand-wrap">
|
||||
<a class="brand app-brand"
|
||||
href="{% if user %}{% if user.preferred_home_view == 'month' %}/month?view={{ user.preferred_month_view_mode or 'flat' }}{% else %}/dashboard{% endif %}{% else %}/{% endif %}"
|
||||
aria-label="{{ app_name }} Startseite">
|
||||
<img class="app-logo" src="/img/Logo.svg" alt="{{ app_name }}" />
|
||||
</a>
|
||||
{% if user %}
|
||||
<div class="app-total-badges">
|
||||
{% if header_vacation_visible is not defined or header_vacation_visible %}
|
||||
<span class="app-total-badge app-total-badge-vacation"
|
||||
title="Resturlaub / Gesamturlaubstage ({{ header_vacation_year }})">
|
||||
<span class="app-total-badge__label">Urlaub</span>
|
||||
<span class="app-total-badge__value">{{ header_vacation_days_remaining }}/{{ header_vacation_days_total }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if header_workhours_counter_visible and header_workhours_counter_minutes is not none %}
|
||||
<span class="app-total-badge app-total-badge-workhours"
|
||||
title="Arbeitsstunden-Counter">
|
||||
<span class="app-total-badge__label">Gesamt</span>
|
||||
<span class="app-total-badge__value">{{ minutes_to_hhmm(header_workhours_counter_minutes) }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if header_cumulative_minutes is not none %}
|
||||
<span class="app-total-badge {% if header_cumulative_minutes < 0 %}is-negative{% else %}is-positive{% endif %}"
|
||||
title="Kumulierte Überstunden">
|
||||
<span class="app-total-badge__label">Stunden</span>
|
||||
<span class="app-total-badge__value">{{ ('%.2f'|format(header_cumulative_minutes / 60) )|replace('.00', '') }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "partials/nav.html" %}
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,38 @@
|
||||
{% from "ui/segmented_toggle.html" import segmented_toggle %}
|
||||
|
||||
{% if user %}
|
||||
<div class="app-user-nav">
|
||||
{{ segmented_toggle([
|
||||
{'href': main_nav_week_url, 'label': 'Woche', 'active': request.url.path.startswith('/dashboard')},
|
||||
{'href': main_nav_month_url, 'label': 'Monat', 'active': request.url.path.startswith('/month')}
|
||||
], 'Hauptnavigation', 'topbar-toggle app-main-nav') }}
|
||||
<div class="app-icon-nav" aria-label="Kopfzeilen-Aktionen">
|
||||
<a class="app-icon-btn"
|
||||
href="/settings"
|
||||
title="Einstellungen"
|
||||
aria-label="Einstellungen">
|
||||
<img class="dash-icon" src="/static/icons/settings.svg" alt="" />
|
||||
</a>
|
||||
<a class="app-icon-btn"
|
||||
href="/hilfe"
|
||||
title="Hilfe"
|
||||
aria-label="Hilfe">
|
||||
<img class="dash-icon" src="/img/Icon-Help.svg" alt="" />
|
||||
</a>
|
||||
<form action="/logout" method="post" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit"
|
||||
class="app-icon-btn"
|
||||
title="Abmelden"
|
||||
aria-label="Abmelden">
|
||||
<img class="dash-icon" src="/static/icons/logout.svg" alt="" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ segmented_toggle([
|
||||
{'href': '/login', 'label': 'Login', 'active': request.url.path.startswith('/login')},
|
||||
{'href': '/register', 'label': 'Registrierung', 'active': request.url.path.startswith('/register')}
|
||||
], 'Authentifizierung', 'auth-toggle app-auth-nav') }}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% if user and user.role == 'admin' %}
|
||||
<span class="admin-version-badge" aria-label="Version {{ app_version }}{% if app_env != 'production' %} {{ app_env|capitalize }}{% endif %}">
|
||||
v{{ app_version }}{% if app_env != 'production' %} {{ app_env|capitalize }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% macro badge(text, tone='default', class_name='') -%}
|
||||
<span class="badge badge--{{ tone }} {{ class_name }}">{{ text }}</span>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% macro button(label, type='button', variant='primary', extra_class='', icon=None) -%}
|
||||
<button type="{{ type }}" class="btn btn--{{ variant }} {{ extra_class }}">
|
||||
{% if icon %}<span class="btn__icon" aria-hidden="true">{{ icon }}</span>{% endif %}
|
||||
<span>{{ label }}</span>
|
||||
</button>
|
||||
{%- endmacro %}
|
||||
{% macro link_button(label, href, variant='primary', extra_class='') -%}
|
||||
<a href="{{ href }}" class="btn btn--{{ variant }} {{ extra_class }}">{{ label }}</a>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% macro card(class_name='') -%}
|
||||
<section class="card {{ class_name }}">
|
||||
{{ caller() }}
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% macro chip(text, kind='default', extra_class='') -%}
|
||||
<span class="ui-chip ui-chip--{{ kind }} {{ extra_class }}">{{ text }}</span>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% macro collapsible_section(title, section_id, classes='', sync_group='') -%}
|
||||
<details id="{{ section_id }}"
|
||||
class="settings-section settings-section--collapsible form-card full-width {{ classes }}"
|
||||
data-component="settings-section"
|
||||
{% if sync_group %}data-sync-group="{{ sync_group }}"{% endif %}>
|
||||
<summary class="settings-section__summary">
|
||||
<span class="settings-section__heading">{{ title }}</span>
|
||||
<span class="settings-section__chevron" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="settings-section__content">{{ caller() }}</div>
|
||||
</details>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% from "ui/chip.html" import chip %}
|
||||
|
||||
{% macro status_badge_form(csrf_token, day, return_to, kind) -%}
|
||||
{% if kind == 'vacation' %}
|
||||
<form method="post"
|
||||
action="/vacation/day/toggle"
|
||||
class="inline-form"
|
||||
data-async-refresh="view">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<button type="submit" class="day-status-badge is-vacation" title="Urlaub entfernen" aria-label="Urlaub entfernen">U</button>
|
||||
</form>
|
||||
{% elif kind == 'holiday' %}
|
||||
<form method="post"
|
||||
action="/special-day/toggle"
|
||||
class="inline-form"
|
||||
data-async-refresh="view">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
|
||||
<input type="hidden" name="status" value="holiday" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<button type="submit" class="day-status-badge is-holiday" title="Feiertag entfernen" aria-label="Feiertag entfernen">F</button>
|
||||
</form>
|
||||
{% elif kind == 'sick' %}
|
||||
<form method="post"
|
||||
action="/special-day/toggle"
|
||||
class="inline-form"
|
||||
data-async-refresh="view">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
|
||||
<input type="hidden" name="status" value="sick" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<button type="submit" class="day-status-badge is-sick" title="Krankheitstag entfernen" aria-label="Krankheitstag entfernen">K</button>
|
||||
</form>
|
||||
{% elif kind == 'overtime' %}
|
||||
<form method="post" action="/overtime-adjustment/clear" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
<button type="submit" class="day-status-badge is-overtime" title="Stundenausgleich entfernen" aria-label="Stundenausgleich entfernen">S</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro add_menu_status_form(action_url, csrf_token, day, return_to, label, status='') -%}
|
||||
<form method="post"
|
||||
action="{{ action_url }}"
|
||||
class="inline-form"
|
||||
data-async-refresh="view">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}" />
|
||||
{% if status %}<input type="hidden" name="status" value="{{ status }}" />{% endif %}
|
||||
<button type="submit" class="day-row__add-menu-item">{{ label }}</button>
|
||||
</form>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro day_row(day, csrf_token, date_label, return_to='/dashboard', mode='week') -%}
|
||||
{% set is_vacation = day.is_vacation if day.is_vacation is defined else false %}
|
||||
{% set special_status = day.special_status if day.special_status is defined else None %}
|
||||
{% set overtime_adjustment_minutes = day.overtime_adjustment_minutes if day.overtime_adjustment_minutes is defined else 0 %}
|
||||
{% set has_status = is_vacation or special_status in ['holiday', 'sick'] or overtime_adjustment_minutes %}
|
||||
{% set is_weekend = day.is_weekend if day.is_weekend is defined else day.date.weekday() >= 5 %}
|
||||
{% set is_today = today_date is defined and day.date == today_date %}
|
||||
{% set status_edit_url = None %}
|
||||
{% if overtime_adjustment_minutes %}
|
||||
{% set status_edit_url = '/overtime-adjustment/edit?date=' ~ day.date.isoformat() %}
|
||||
{% elif is_vacation %}
|
||||
{% set status_edit_url = '/day-status/edit?date=' ~ day.date.isoformat() ~ '&status=vacation' %}
|
||||
{% elif special_status == 'holiday' %}
|
||||
{% set status_edit_url = '/day-status/edit?date=' ~ day.date.isoformat() ~ '&status=holiday' %}
|
||||
{% elif special_status == 'sick' %}
|
||||
{% set status_edit_url = '/day-status/edit?date=' ~ day.date.isoformat() ~ '&status=sick' %}
|
||||
{% endif %}
|
||||
|
||||
<article class="day-row day-row--{{ mode }} {% if is_weekend %}day-row--weekend{% endif %} {% if is_today %}day-row--today{% endif %} {% if has_status %}day-row--has-status{% endif %}">
|
||||
<div class="day-row__label" title="{{ date_label }}">{{ date_label }}</div>
|
||||
|
||||
<div class="day-row__cells {% if not day.entry %}day-row__cells--empty{% endif %}">
|
||||
{% if day.entry %}
|
||||
{{ chip(minutes_to_hhmm(day.entry.start_minutes) ~ ' → ' ~ minutes_to_hhmm(day.entry.end_minutes), 'time') }}
|
||||
{{ chip('Pause: ' ~ day.entry.break_minutes ~ ' min', 'break') }}
|
||||
{{ chip('Netto: ' ~ minutes_to_hhmm(day.net_minutes), 'net') }}
|
||||
{% else %}
|
||||
{{ chip('Keinen Eintrag', 'empty') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="day-row__actions">
|
||||
{% if is_vacation %}{{ status_badge_form(csrf_token, day, return_to, 'vacation') }}{% endif %}
|
||||
{% if special_status == 'holiday' %}{{ status_badge_form(csrf_token, day, return_to, 'holiday') }}{% endif %}
|
||||
{% if special_status == 'sick' %}{{ status_badge_form(csrf_token, day, return_to, 'sick') }}{% endif %}
|
||||
{% if overtime_adjustment_minutes %}{{ status_badge_form(csrf_token, day, return_to, 'overtime') }}{% endif %}
|
||||
|
||||
{% if day.entry %}
|
||||
<a class="icon-button" href="/entry/{{ day.entry.id }}/edit" title="Eintrag bearbeiten" aria-label="Eintrag bearbeiten">
|
||||
<img class="dash-icon" src="/static/icons/edit.svg" alt="" />
|
||||
</a>
|
||||
<form method="post" action="/entry/{{ day.entry.id }}/delete" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit" class="icon-button" title="Eintrag löschen" aria-label="Eintrag löschen">
|
||||
<img class="dash-icon" src="/static/icons/delete.svg" alt="" />
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
{% if has_status and status_edit_url %}
|
||||
<a class="icon-button" href="{{ status_edit_url }}" title="Tag bearbeiten" aria-label="Tag bearbeiten">
|
||||
<img class="dash-icon" src="/static/icons/edit.svg" alt="" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<details class="day-row__add-menu">
|
||||
<summary class="icon-button" title="Optionen hinzufügen" aria-label="Optionen hinzufügen">
|
||||
<img class="dash-icon" src="/static/icons/add.svg" alt="" />
|
||||
</summary>
|
||||
<div class="day-row__add-menu-panel">
|
||||
<a class="day-row__add-menu-item" href="/entry/new?date={{ day.date.isoformat() }}">Zeit</a>
|
||||
{{ add_menu_status_form('/vacation/day/toggle', csrf_token, day, return_to, 'Urlaub (U)') }}
|
||||
{{ add_menu_status_form('/special-day/toggle', csrf_token, day, return_to, 'Feiertag (F)', 'holiday') }}
|
||||
{{ add_menu_status_form('/special-day/toggle', csrf_token, day, return_to, 'Krankheit (K)', 'sick') }}
|
||||
<a class="day-row__add-menu-item" href="/overtime-adjustment/edit?date={{ day.date.isoformat() }}">Stundenausgleich (S)</a>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,6 @@
|
||||
{% macro empty_state(title, text='') -%}
|
||||
<section class="empty-state">
|
||||
<h2 class="empty-state__title">{{ title }}</h2>
|
||||
{% if text %}<p class="empty-state__text muted">{{ text }}</p>{% endif %}
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% macro alert(message, level='info') -%}
|
||||
<div class="alert alert--{{ level }}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-component="flash">
|
||||
<span class="alert__message">{{ message }}</span>
|
||||
<button class="alert__close"
|
||||
type="button"
|
||||
aria-label="Hinweis schließen"
|
||||
data-action="flash-close">×</button>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% macro input_field(label, name, type='text', value='', required=false, placeholder='', autocomplete='', extra_class='', attrs='') -%}
|
||||
<label class="form-field {{ extra_class }}">
|
||||
<span class="form-field__label">{{ label }}</span>
|
||||
<input class="input"
|
||||
type="{{ type }}"
|
||||
name="{{ name }}"
|
||||
value="{{ value }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
|
||||
{% if attrs %}{{ attrs|safe }}{% endif %} />
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
{% macro textarea_field(label, name, value='', rows=3, placeholder='', extra_class='', attrs='') -%}
|
||||
<label class="form-field {{ extra_class }}">
|
||||
<span class="form-field__label">{{ label }}</span>
|
||||
<textarea class="input"
|
||||
name="{{ name }}"
|
||||
rows="{{ rows }}"
|
||||
{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if attrs %} {{ attrs|safe }}{% endif %}>{{ value }}</textarea>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
{% macro select_field(label, name, options, selected='', required=false, extra_class='', attrs='') -%}
|
||||
<label class="form-field {{ extra_class }}">
|
||||
<span class="form-field__label">{{ label }}</span>
|
||||
<select class="input"
|
||||
name="{{ name }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if attrs %}{{ attrs|safe }}{% endif %}>
|
||||
{% for option in options %}
|
||||
<option value="{{ option.value }}"
|
||||
{% if option.value == selected %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% macro help_section(title, subtitle='') -%}
|
||||
<article class="help-section">
|
||||
<header class="help-section__header">
|
||||
<h2>{{ title }}</h2>
|
||||
{% if subtitle %}<p class="muted">{{ subtitle }}</p>{% endif %}
|
||||
</header>
|
||||
<div class="help-section__body">{{ caller() }}</div>
|
||||
</article>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% macro icon_link(href, icon, label, title='', extra_class='') -%}
|
||||
<a class="icon-button {{ extra_class }}"
|
||||
href="{{ href }}"
|
||||
title="{{ title if title else label }}"
|
||||
aria-label="{{ label }}">
|
||||
<img class="dash-icon" src="{{ icon }}" alt="" />
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro icon_submit(icon, label, title='', extra_class='') -%}
|
||||
<button type="submit"
|
||||
class="icon-button {{ extra_class }}"
|
||||
title="{{ title if title else label }}"
|
||||
aria-label="{{ label }}">
|
||||
<img class="dash-icon" src="{{ icon }}" alt="" />
|
||||
</button>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% macro kpi_bar(items, extra_class='') -%}
|
||||
<section class="kpi-bar {{ extra_class }}">
|
||||
{% for item in items %}
|
||||
<p class="kpi-bar__item {% if item.get('show_edit') %}kpi-bar__item--editable{% endif %}">
|
||||
<span class="kpi-bar__label">{{ item.label }}:</span>
|
||||
<strong class="kpi-bar__value {% if item.get('value_class') %}{{ item.get('value_class') }}{% endif %}">{{ item.value }}</strong>
|
||||
{% if item.get('show_edit') %}
|
||||
<button type="button"
|
||||
class="kpi-bar__edit js-toggle-weekly-target-editor"
|
||||
aria-label="Wochen-Soll bearbeiten"
|
||||
title="Wochen-Soll bearbeiten">
|
||||
<img class="dash-icon" src="/static/icons/edit.svg" alt="" />
|
||||
</button>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% macro modal(id, title, close_label='Schließen') -%}
|
||||
<div class="modal" id="{{ id }}" data-component="modal" hidden>
|
||||
<div class="modal__backdrop" data-action="modal-close"></div>
|
||||
<section class="modal__dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="{{ id }}-title">
|
||||
<header class="modal__header">
|
||||
<h2 id="{{ id }}-title">{{ title }}</h2>
|
||||
<button class="modal__close"
|
||||
type="button"
|
||||
data-action="modal-close"
|
||||
aria-label="{{ close_label }}">×</button>
|
||||
</header>
|
||||
<div class="modal__body">{{ caller() }}</div>
|
||||
</section>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||