├── static ├── input.css └── output.css ├── src ├── config │ ├── __init__.py │ └── AppConfig.py ├── bots │ ├── __init__.py │ └── birthday_bot.py ├── context │ ├── __init__.py │ └── depend_context.py ├── parse │ ├── __init__.py │ ├── EmployeeBirthdayReader.py │ ├── EmployeeBioReader.py │ ├── HotSchedulesStaffReader.py │ └── TimePunchReader.py ├── __init__.py ├── log │ └── __init__.py ├── routes │ ├── post_api_groupme_birthday.py │ ├── __init__.py │ ├── post_tbot_birthday.py │ ├── get_admin.py │ ├── get_index.py │ ├── post_form_employee_update_department.py │ ├── post_form_upload_employee_bio.py │ ├── get_form_logout.py │ ├── get_admin_locations.py │ ├── post_form_upload_hotschedules_staff_html.py │ ├── post_form_cfa_location_delete.py │ ├── post_form_cfa_location_create.py │ ├── post_form_login.py │ ├── post_form_upload_employee_birthday_report.py │ ├── post_form_upload_time_punch.py │ └── get_admin_cfa_location.py ├── middleware │ └── __init__.py └── db │ ├── __init__.py │ ├── Location.py │ ├── Session.py │ └── Employee.py ├── init.sh ├── Makefile ├── pyproject.toml ├── templates ├── page │ ├── admin │ │ ├── home.html │ │ ├── locations.html │ │ └── location.html │ └── guest │ │ └── home.html └── layout │ ├── guest.html │ └── admin.html ├── .gitignore ├── README.md ├── main.py └── birthday_bot.py /static/input.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .AppConfig import * 2 | -------------------------------------------------------------------------------- /src/bots/__init__.py: -------------------------------------------------------------------------------- 1 | from .birthday_bot import process_daily_birthdays -------------------------------------------------------------------------------- /src/context/__init__.py: -------------------------------------------------------------------------------- 1 | from .depend_context import depend_context 2 | -------------------------------------------------------------------------------- /src/parse/__init__.py: -------------------------------------------------------------------------------- 1 | from .EmployeeBioReader import * 2 | from .EmployeeBirthdayReader import * 3 | from .HotSchedulesStaffReader import * 4 | from .TimePunchReader import * 5 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import * 2 | from .context import * 3 | from .db import * 4 | from .log import * 5 | from .middleware import * 6 | from .parse import * 7 | from .routes import * 8 | -------------------------------------------------------------------------------- /src/context/depend_context.py: -------------------------------------------------------------------------------- 1 | from fastapi.templating import Jinja2Templates 2 | 3 | 4 | def depend_context(): 5 | templates = Jinja2Templates(directory="templates") 6 | return {"templates": templates} 7 | -------------------------------------------------------------------------------- /init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "ADMIN_ID=1323123212123123" >> .env 3 | echo "SQLITE_ABSOLUTE_PATH=./main.db" >> .env 4 | echo "ADMIN_USERNAME=cow" >> .env 5 | echo "ADMIN_PASSWORD=chicken" >> .env 6 | echo "TBOT_KEY=asdasdaqweqweqadas" >> .env 7 | mkdir static 8 | echo "@import 'tailwindcss';" >> ./static/input.css -------------------------------------------------------------------------------- /src/log/__init__.py: -------------------------------------------------------------------------------- 1 | def logi(msg): 2 | with open("./temp/logi.log", "w") as f: 3 | f.write(msg) 4 | 5 | 6 | def logi_clear(): 7 | logi("") 8 | 9 | 10 | def logi_append(msg): 11 | with open("./temp/logi.log", "a") as f: 12 | f.write(msg) 13 | f.write("\n\n") 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | run: 3 | uv run uvicorn main:app --reload 4 | 5 | install: 6 | uv pip install -r requirements.txt 7 | 8 | format: 9 | uv run black .; uv run isort .; 10 | 11 | bday: 12 | uv run python birthday_bot.py 13 | 14 | tw: 15 | tailwindcss -i "./static/input.css" -o "./static/output.css" --watch -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cfasuite" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "bs4>=0.0.2", 9 | "fastapi[standard]>=0.116.1", 10 | "openpyxl>=3.1.5", 11 | "pandas>=2.3.2", 12 | "pypdf2>=3.0.1", 13 | "uvicorn>=0.35.0", 14 | ] 15 | -------------------------------------------------------------------------------- /src/routes/post_api_groupme_birthday.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.responses import JSONResponse 3 | 4 | from ..config import AppConfig 5 | 6 | 7 | def post_api_groupme_birthday(app: FastAPI, config: AppConfig): 8 | app.post("/api/groupme/birthday") 9 | 10 | async def post_api_groupme_bithday(): 11 | return JSONResponse(status_code=200, content={"message": "hit"}) 12 | -------------------------------------------------------------------------------- /templates/page/admin/home.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/admin.html" %} 2 | {% block title %}Admin Home{% endblock %} 3 | {% block content %} 4 |
5 |
6 |

Admin Dashboard - CFA Suite

7 |
8 |
9 | {% endblock %} -------------------------------------------------------------------------------- /src/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | 3 | from src.db import * 4 | 5 | 6 | def middleware_auth(c: Cursor, request: Request, user_id: str): 7 | session_id = request.cookies.get("APP_SESSION_ID") 8 | if session_id == None: 9 | return None 10 | session = Session.sqlite_get_by_id(c, session_id) 11 | if session == None: 12 | return None 13 | if str(session.user_id) != str(user_id): 14 | return None 15 | return session 16 | -------------------------------------------------------------------------------- /src/db/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sqlite3 import Connection, connect 3 | 4 | from .Employee import * 5 | from .Location import * 6 | from .Session import * 7 | 8 | 9 | def sqlite_connection(path: str): 10 | conn: Connection = connect(path) 11 | return conn, conn.cursor() 12 | 13 | 14 | def init_tables(path: str): 15 | init_conn, init_cursor = sqlite_connection(path) 16 | Location.sqlite_create_table(init_cursor) 17 | Employee.sqlite_create_table(init_cursor) 18 | Session.sqlite_create_table(init_cursor) 19 | init_conn.commit() 20 | init_conn.close() 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.do 2 | *.xlsx 3 | 4 | # dependencies (bun install) 5 | node_modules 6 | 7 | # output 8 | out 9 | dist 10 | *.tgz 11 | 12 | # code coverage 13 | coverage 14 | *.lcov 15 | 16 | # logs 17 | logs 18 | _.log 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # dotenv environment variable files 22 | .env 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | .env.local 27 | 28 | # caches 29 | .eslintcache 30 | .cache 31 | *.tsbuildinfo 32 | 33 | # IntelliJ based IDEs 34 | .idea 35 | 36 | # Finder (MacOS) folder config 37 | .DS_Store 38 | 39 | *.db 40 | temp 41 | __pycache__/ 42 | venv -------------------------------------------------------------------------------- /src/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from .get_admin import * 2 | from .get_admin_cfa_location import * 3 | from .get_admin_locations import * 4 | from .get_form_logout import * 5 | from .get_index import * 6 | from .post_api_groupme_birthday import * 7 | from .post_form_cfa_location_create import * 8 | from .post_form_cfa_location_delete import * 9 | from .post_form_employee_update_department import * 10 | from .post_form_login import * 11 | from .post_form_upload_employee_bio import * 12 | from .post_form_upload_employee_birthday_report import * 13 | from .post_form_upload_hotschedules_staff_html import * 14 | from .post_form_upload_time_punch import * 15 | from .post_tbot_birthday import * -------------------------------------------------------------------------------- /src/routes/post_tbot_birthday.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException 2 | from fastapi.responses import JSONResponse 3 | 4 | from ..config import AppConfig 5 | from ..bots import process_daily_birthdays 6 | 7 | 8 | def post_tbot_birthday(app: FastAPI, config: AppConfig): 9 | @app.post("/tbot/birthday") 10 | async def post_tbot_birthday_endpoint(key: str): 11 | if key != config.tbot_key: 12 | return JSONResponse(status_code=401, content={"message": "Unauthorized"}) 13 | 14 | try: 15 | # Execute the existing bot logic 16 | process_daily_birthdays() 17 | return JSONResponse(status_code=200, content={"message": "Birthday bot executed successfully"}) 18 | except Exception as e: 19 | return JSONResponse(status_code=500, content={"message": f"Error running bot: {str(e)}"}) -------------------------------------------------------------------------------- /src/routes/get_admin.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, FastAPI, Request 2 | from fastapi.responses import RedirectResponse 3 | 4 | from ..config import AppConfig 5 | from ..context import depend_context 6 | from ..middleware import middleware_auth, sqlite_connection 7 | 8 | 9 | def get_admin(app: FastAPI, config: AppConfig): 10 | @app.get("/admin") 11 | async def get_admin(request: Request, context=Depends(depend_context)): 12 | conn, c = sqlite_connection(config.sqlite_absolute_path) 13 | session = middleware_auth(c, request, config.admin_id) 14 | if session == None: 15 | return RedirectResponse("/", 303) 16 | conn.close() 17 | return context["templates"].TemplateResponse( 18 | request, 19 | "page/admin/home.html", 20 | { 21 | "session_key": session.key, 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CFA Suite 2 | A web application with internal tools I use for my role at Chick-fil-A. 3 | 4 | ## Installation 5 | 6 | 1. clone the repo: 7 | ```bash 8 | git clone https://github.com/phillip-england/cfasuite 9 | cd cfasuite 10 | ``` 11 | 12 | 2. Create a `.env`: 13 | ```bash 14 | touch .env 15 | ``` 16 | 17 | 3. Provide .env variables in `./env`: 18 | ```bash 19 | SQLITE_ABSOLUTE_PATH= ## where you want your sqlite.db to exist 20 | ADMIN_ID=99999999 ## a random number (preferable 1000000+) 21 | ADMIN_USERNAME=someusername ## a username for the admin user 22 | ADMIN_PASSWORD=somepassword ## a password for the admin user 23 | TBOT_KEY=some_key_required_to_use_bot_endpoints ## choose carefully 24 | ## *inserts env varibles for groupme bots* 25 | ``` 26 | 27 | 4. Init a virtual env 28 | ```bash 29 | uv venv 30 | ``` 31 | 32 | 5. Install deps: 33 | ```bash 34 | uv sync 35 | ``` 36 | 37 | ## Running 38 | 39 | ```bash 40 | uv run uvicorn main:app --reload 41 | ## or 'make run' 42 | ``` 43 | -------------------------------------------------------------------------------- /src/routes/get_index.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, FastAPI, Request 2 | from fastapi.responses import RedirectResponse 3 | 4 | from src import * 5 | 6 | from ..config import AppConfig 7 | from ..context import depend_context 8 | from ..db import Session 9 | from ..middleware import sqlite_connection 10 | 11 | 12 | def get_index(app: FastAPI, config: AppConfig): 13 | @app.get("/") 14 | async def get_index(request: Request, context=Depends(depend_context)): 15 | conn, c = sqlite_connection(config.sqlite_absolute_path) 16 | session_id = request.cookies.get("APP_SESSION_ID") 17 | session = Session.sqlite_get_by_id(c, session_id) 18 | if session != None: 19 | if int(session.user_id) == int(config.admin_id): 20 | conn.close() 21 | return RedirectResponse("/admin", status_code=303) 22 | conn.close() 23 | return context["templates"].TemplateResponse( 24 | request, "page/guest/home.html", {} 25 | ) 26 | -------------------------------------------------------------------------------- /src/parse/EmployeeBirthdayReader.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | from fastapi import UploadFile 4 | from pandas import read_excel 5 | 6 | 7 | class EmployeeBirthdayReader: 8 | def __init__(self, birthday_dict: dict): 9 | self.birthday_dict = birthday_dict 10 | return 11 | 12 | @staticmethod 13 | async def new(file: UploadFile): 14 | birthday_dict = {} 15 | file_contents = await file.read() 16 | df = read_excel(BytesIO(file_contents)) 17 | for i, row in df.iterrows(): 18 | name = row["Employee Name"] 19 | bday = row["Birth Date"] 20 | bday_parts = bday.split("/") 21 | bday_final = f"{bday_parts[2]}-{bday_parts[0]}-{bday_parts[1]}" 22 | birthday_dict[name] = bday_final 23 | # employee_status = row["Employee Status"] 24 | # if employee_status == "Terminated": 25 | # continue 26 | # name = row["Employee Name"] 27 | # names.append(name) 28 | return EmployeeBirthdayReader(birthday_dict) 29 | -------------------------------------------------------------------------------- /templates/layout/guest.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head %} 5 | 6 | 7 | {% block title %}{% endblock %} - CFA Suite 8 | 9 | 10 | 11 | 14 | {% endblock %} 15 | 16 | 17 | 24 |
25 | {% block content %}{% endblock %} 26 |
27 | 28 | -------------------------------------------------------------------------------- /src/routes/post_form_employee_update_department.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Form, Request 2 | from fastapi.responses import RedirectResponse 3 | 4 | from ..config import AppConfig 5 | from ..db import Employee 6 | from ..middleware import middleware_auth, sqlite_connection 7 | 8 | 9 | def post_form_employee_update_department(app: FastAPI, config: AppConfig): 10 | @app.post("/form/employee/update/department") 11 | async def post_form_employee_update_department( 12 | request: Request, 13 | department: str | None = Form(None), 14 | employee_id: str | None = Form(None), 15 | location_id: str | None = Form(None), 16 | session_key: str | None = Form(None), 17 | ): 18 | conn, c = sqlite_connection(config.sqlite_absolute_path) 19 | session = middleware_auth(c, request, config.admin_id) 20 | if session == None or session_key != session.key: 21 | return RedirectResponse("/", 303) 22 | Employee.sqlite_update_department(c, employee_id, department) 23 | conn.commit() 24 | conn.close() 25 | return RedirectResponse(f"/admin/cfa_location/{location_id}", 303) 26 | -------------------------------------------------------------------------------- /src/routes/post_form_upload_employee_bio.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import FastAPI, File, Form, Request, UploadFile 4 | from fastapi.responses import RedirectResponse 5 | 6 | from ..config import AppConfig 7 | from ..middleware import middleware_auth, sqlite_connection 8 | from ..parse import EmployeeBioReader 9 | 10 | 11 | def post_form_upload_employee_bio(app: FastAPI, config: AppConfig): 12 | @app.post("/form/upload/employee_bio") 13 | async def post_form_upload_employee_bio( 14 | request: Request, 15 | file: Annotated[UploadFile, File()], 16 | cfa_location_id: str | None = Form(None), 17 | ): 18 | conn, c = sqlite_connection(config.sqlite_absolute_path) 19 | session = middleware_auth(c, request, config.admin_id) 20 | if session == None: 21 | return RedirectResponse("/", 303) 22 | reader = await EmployeeBioReader.new(file) 23 | reader.insert_all_employees(conn, c, cfa_location_id) 24 | reader.remove_terminated_employees(conn, c, cfa_location_id) 25 | conn.commit() 26 | conn.close() 27 | return RedirectResponse( 28 | url=f"/admin/cfa_location/{cfa_location_id}", status_code=303 29 | ) 30 | -------------------------------------------------------------------------------- /src/routes/get_form_logout.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import FastAPI 4 | from fastapi.responses import RedirectResponse, Response 5 | 6 | from ..config import AppConfig 7 | from ..db import Session 8 | from ..middleware import sqlite_connection 9 | 10 | 11 | def get_form_logout(app: FastAPI, config: AppConfig): 12 | @app.get("/form/logout") 13 | async def get_form_logout(response: Response, session_key: Optional[str] = None): 14 | conn, c = sqlite_connection(config.sqlite_absolute_path) 15 | if session_key == None: 16 | return RedirectResponse("/", status_code=303) 17 | current_session = Session.sqlite_get_by_user_id(c, config.admin_id) 18 | if current_session == None: 19 | return RedirectResponse("/", status_code=303) 20 | if session_key != current_session.key: 21 | return RedirectResponse("/", status_code=303) 22 | Session.sqlite_delete_by_session_key(c, session_key) 23 | conn.commit() 24 | conn.close() 25 | response = RedirectResponse(url="/", status_code=303) 26 | response.delete_cookie( 27 | "APP_SESSION_ID", httponly=True, secure=True, samesite="lax" 28 | ) 29 | return response 30 | -------------------------------------------------------------------------------- /src/routes/get_admin_locations.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import Depends, FastAPI, Request 4 | from fastapi.responses import RedirectResponse 5 | 6 | from ..config import AppConfig 7 | from ..context import depend_context 8 | from ..db import Location 9 | from ..middleware import middleware_auth, sqlite_connection 10 | 11 | 12 | def get_admin_locations(app: FastAPI, config: AppConfig): 13 | @app.get("/admin/cfa_locations") 14 | async def get_admin_locations( 15 | request: Request, 16 | err_create_location: Optional[str] = "", 17 | context=Depends(depend_context), 18 | ): 19 | conn, c = sqlite_connection(config.sqlite_absolute_path) 20 | session = middleware_auth(c, request, config.admin_id) 21 | if session == None: 22 | return RedirectResponse("/", 303) 23 | cfa_locations = Location.sqlite_find_all(c) 24 | conn.close() 25 | return context["templates"].TemplateResponse( 26 | request, 27 | "page/admin/locations.html", 28 | { 29 | "cfa_locations": cfa_locations, 30 | "err_create_location": err_create_location, 31 | "session_key": session.key, 32 | }, 33 | ) 34 | -------------------------------------------------------------------------------- /src/routes/post_form_upload_hotschedules_staff_html.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import FastAPI, File, Form, Request, UploadFile 4 | from fastapi.responses import RedirectResponse 5 | from pandas import read_excel 6 | 7 | from ..config import AppConfig 8 | from ..db import Employee 9 | from ..middleware import middleware_auth, sqlite_connection 10 | from ..parse import HotSchedulesStaffReader 11 | 12 | 13 | def post_form_upload_hotschedules_staff_html(app: FastAPI, config: AppConfig): 14 | @app.post("/form/upload/hotschedules_staff_html") 15 | async def post_form_upload_hotschedules_staff_html( 16 | request: Request, 17 | cfa_location_id: str | None = Form(None), 18 | html: str | None = Form(None), 19 | ): 20 | conn, c = sqlite_connection(config.sqlite_absolute_path) 21 | session = middleware_auth(c, request, config.admin_id) 22 | if session == None: 23 | return RedirectResponse("/", 303) 24 | employees = Employee.sqlite_find_all_by_cfa_location_id(c, cfa_location_id) 25 | 26 | reader = await HotSchedulesStaffReader.new(c, html, employees) 27 | conn.commit() 28 | return RedirectResponse( 29 | url=f"/admin/cfa_location/{cfa_location_id}", status_code=303 30 | ) 31 | -------------------------------------------------------------------------------- /src/routes/post_form_cfa_location_delete.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Form, Request 2 | from fastapi.responses import JSONResponse, RedirectResponse 3 | 4 | from ..config import AppConfig 5 | from ..db import Location 6 | from ..middleware import middleware_auth, sqlite_connection 7 | 8 | 9 | def post_form_cfa_location_delete(app: FastAPI, config: AppConfig): 10 | @app.post("/form/cfa_location/delete/{id}") 11 | async def post_form_cfa_location_delete( 12 | request: Request, 13 | id: int, 14 | cfa_location_number: int | None = Form(None), 15 | session_key: str | None = Form(None), 16 | ): 17 | conn, c = sqlite_connection(config.sqlite_absolute_path) 18 | session = middleware_auth(c, request, config.admin_id) 19 | if session == None or session_key != session.key: 20 | return RedirectResponse("/", 303) 21 | cfa_location = Location.sqlite_find_by_id(c, id) 22 | if cfa_location == None: 23 | return JSONResponse({"message": "unauthorized"}, 401) 24 | if cfa_location.number != cfa_location_number: 25 | return JSONResponse({"message": "unauthorized"}, 401) 26 | Location.sqlite_delete_by_id(c, id) 27 | conn.commit() 28 | conn.close() 29 | return RedirectResponse("/admin/cfa_locations", 303) 30 | -------------------------------------------------------------------------------- /src/routes/post_form_cfa_location_create.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Form, Request 2 | from fastapi.responses import RedirectResponse 3 | 4 | from ..config import AppConfig 5 | from ..db import Location 6 | from ..middleware import middleware_auth, sqlite_connection 7 | 8 | 9 | def post_form_cfa_location_create(app: FastAPI, config: AppConfig): 10 | @app.post("/form/cfa_location/create") 11 | async def post_form_cfa_location_create( 12 | request: Request, 13 | name: str | None = Form(None), 14 | number: str | None = Form(None), 15 | session_key: str | None = Form(None), 16 | ): 17 | conn, c = sqlite_connection(config.sqlite_absolute_path) 18 | session = middleware_auth(c, request, config.admin_id) 19 | if session == None or session_key != session.key: 20 | return RedirectResponse("/", 303) 21 | potential_location = Location.sqlite_find_by_number(c, number) 22 | if potential_location != None: 23 | return RedirectResponse( 24 | "/admin/cfa_locations?err_create_location=location with provided number already exists", 25 | 303, 26 | ) 27 | Location.sqlite_insert_one(c, name, number) 28 | conn.commit() 29 | conn.close() 30 | return RedirectResponse(url="/admin/cfa_locations", status_code=303) 31 | -------------------------------------------------------------------------------- /src/routes/post_form_login.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Form, Request 2 | from fastapi.responses import RedirectResponse, Response 3 | 4 | from ..config import AppConfig 5 | from ..db import Session 6 | from ..middleware import sqlite_connection 7 | 8 | 9 | def post_form_login(app: FastAPI, config: AppConfig): 10 | @app.post("/form/login") 11 | async def post_form_login( 12 | response: Response, 13 | request: Request, 14 | username: str | None = Form(None), 15 | password: str | None = Form(None), 16 | ): 17 | conn, c = sqlite_connection(config.sqlite_absolute_path) 18 | 19 | if username != config.admin_username or password != config.admin_password: 20 | conn.close() 21 | return RedirectResponse(url="/", status_code=303) 22 | pre_existing_session = Session.sqlite_get_by_user_id(c, config.admin_id) 23 | if pre_existing_session != None: 24 | Session.sqlite_delete_by_id(c, pre_existing_session.id) 25 | session = Session.sqlite_insert(c, config.admin_id) 26 | conn.commit() 27 | conn.close() 28 | response = RedirectResponse(url="/admin", status_code=303) 29 | response.set_cookie( 30 | "APP_SESSION_ID", 31 | session.id, 32 | httponly=True, 33 | secure=True, 34 | samesite="lax", 35 | max_age=1800, 36 | ) 37 | return response 38 | -------------------------------------------------------------------------------- /src/config/AppConfig.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | 6 | class AppConfig: 7 | def __init__( 8 | self, 9 | admin_id: str, 10 | sqlite_absolute_path: str, 11 | admin_username: str, 12 | admin_password: str, 13 | tbot_key: str, 14 | ): 15 | self.admin_id = admin_id 16 | self.sqlite_absolute_path = sqlite_absolute_path 17 | self.admin_username = admin_username 18 | self.admin_password = admin_password 19 | self.tbot_key = tbot_key 20 | return 21 | 22 | @staticmethod 23 | def load(): 24 | load_dotenv() 25 | admin_id = os.getenv("ADMIN_ID") 26 | sqlite_absolute_path = os.getenv("SQLITE_ABSOLUTE_PATH") 27 | admin_username = os.getenv("ADMIN_USERNAME") 28 | admin_password = os.getenv("ADMIN_PASSWORD") 29 | tbot_key = os.getenv("TBOT_KEY") 30 | 31 | if ( 32 | admin_id == None 33 | or sqlite_absolute_path == None 34 | or admin_username == None 35 | or admin_password == None 36 | or tbot_key == None 37 | ): 38 | raise Exception( 39 | "please configure your .env file before serving cfasuite\ncheckout https://github.com/phillip-england/cfasuite for more information" 40 | ) 41 | return AppConfig(admin_id, sqlite_absolute_path, admin_username, admin_password, tbot_key) -------------------------------------------------------------------------------- /src/routes/post_form_upload_employee_birthday_report.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import FastAPI, File, Form, Request, UploadFile 4 | from fastapi.responses import RedirectResponse 5 | 6 | from ..config import AppConfig 7 | from ..db import Employee 8 | from ..middleware import middleware_auth, sqlite_connection 9 | from ..parse import EmployeeBirthdayReader 10 | 11 | 12 | def post_form_upload_employee_birthday_report(app: FastAPI, config: AppConfig): 13 | @app.post("/form/upload/employee_birthday_report") 14 | async def post_form_employee_bio( 15 | request: Request, 16 | file: Annotated[UploadFile, File()], 17 | cfa_location_id: str | None = Form(None), 18 | ): 19 | conn, c = sqlite_connection(config.sqlite_absolute_path) 20 | session = middleware_auth(c, request, config.admin_id) 21 | if session == None: 22 | return RedirectResponse("/", 303) 23 | employees = Employee.sqlite_find_all_by_cfa_location_id(c, cfa_location_id) 24 | reader = await EmployeeBirthdayReader.new(file) 25 | for name in reader.birthday_dict: 26 | bday = reader.birthday_dict[name] 27 | for employee in employees: 28 | if employee.time_punch_name == name: 29 | Employee.sqlite_add_birthday(c, employee.id, bday) 30 | break 31 | conn.commit() 32 | conn.close() 33 | return RedirectResponse( 34 | url=f"/admin/cfa_location/{cfa_location_id}", status_code=303 35 | ) 36 | -------------------------------------------------------------------------------- /src/routes/post_form_upload_time_punch.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import FastAPI, File, Form, Request, UploadFile 4 | from fastapi.responses import RedirectResponse 5 | 6 | from ..config import AppConfig 7 | from ..db import Employee 8 | from ..middleware import middleware_auth, sqlite_connection 9 | from ..parse import TimePunchReader 10 | 11 | 12 | def post_form_upload_time_punch(app: FastAPI, config: AppConfig): 13 | @app.post("/form/upload/time_punch") 14 | async def post_form_upload_time_punch( 15 | request: Request, 16 | file: Annotated[UploadFile, File()], 17 | cfa_location_id: str | None = Form(None), 18 | session_key: str | None = Form(None), 19 | ): 20 | try: 21 | contents = await file.read() 22 | conn, c = sqlite_connection(config.sqlite_absolute_path) 23 | session = middleware_auth(c, request, config.admin_id) 24 | if session == None or session_key != session.key: 25 | return RedirectResponse("/", 303) 26 | current_employees = Employee.sqlite_find_all_by_cfa_location_id( 27 | c, cfa_location_id 28 | ) 29 | time_punch_pdf = TimePunchReader(contents, current_employees) 30 | conn.close() 31 | return RedirectResponse( 32 | url=f"/admin/cfa_location/{cfa_location_id}?time_punch_json={time_punch_pdf.to_json()}", 33 | status_code=303, 34 | ) 35 | except Exception as e: 36 | return {"message": str(e)} 37 | -------------------------------------------------------------------------------- /src/routes/get_admin_cfa_location.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | from fastapi import Depends, FastAPI, Request 5 | from fastapi.responses import RedirectResponse 6 | 7 | from ..config import AppConfig 8 | from ..context import depend_context 9 | from ..db import Employee, Location 10 | from ..middleware import middleware_auth, sqlite_connection 11 | 12 | 13 | def get_admin_cfa_location(app: FastAPI, config: AppConfig): 14 | @app.get("/admin/cfa_location/{location_id}") 15 | async def get_admin_cfa_location( 16 | request: Request, 17 | location_id: int, 18 | time_punch_json: Optional[str] = None, 19 | context=Depends(depend_context), 20 | ): 21 | time_punch_data = None 22 | if time_punch_json: 23 | time_punch_data = json.loads(time_punch_json) 24 | conn, c = sqlite_connection(config.sqlite_absolute_path) 25 | session = middleware_auth(c, request, config.admin_id) 26 | if session == None: 27 | return RedirectResponse("/", 303) 28 | cfa_location = Location.sqlite_find_by_id(c, location_id) 29 | employees = Employee.sqlite_find_all_by_cfa_location_id(c, cfa_location.id) 30 | conn.close() 31 | return context["templates"].TemplateResponse( 32 | request, 33 | "page/admin/location.html", 34 | context={ 35 | "cfa_location": cfa_location, 36 | "employees": employees, 37 | "session_key": session.key, 38 | "time_punch_data": time_punch_data, 39 | }, 40 | ) 41 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from fastapi import Depends, FastAPI, Request 4 | from fastapi.responses import JSONResponse 5 | from fastapi.staticfiles import StaticFiles 6 | from fastapi.templating import Jinja2Templates 7 | 8 | from src import * 9 | 10 | try: 11 | config = AppConfig.load() 12 | init_tables(config.sqlite_absolute_path) 13 | 14 | app = FastAPI(dependencies=[Depends(depend_context)]) 15 | app.mount("/static", StaticFiles(directory="static"), name="static") 16 | templates = Jinja2Templates(directory="templates") 17 | 18 | @app.exception_handler(Exception) 19 | async def global_exception_handler(request: Request, exc: Exception): 20 | return JSONResponse( 21 | status_code=500, content={"message": f"Oops! Something went wrong: {exc}"} 22 | ) 23 | 24 | get_index(app, config) 25 | get_admin(app, config) 26 | post_api_groupme_birthday(app, config) 27 | get_admin_locations(app, config) 28 | get_admin_cfa_location(app, config) 29 | post_form_upload_employee_bio(app, config) 30 | post_form_cfa_location_create(app, config) 31 | post_form_cfa_location_delete(app, config) 32 | post_form_employee_update_department(app, config) 33 | post_form_login(app, config) 34 | post_form_upload_employee_bio(app, config) 35 | post_form_upload_employee_birthday_report(app, config) 36 | post_form_upload_time_punch(app, config) 37 | get_form_logout(app, config) 38 | post_form_upload_hotschedules_staff_html(app, config) 39 | post_tbot_birthday(app, config) 40 | 41 | 42 | except Exception as e: 43 | print(e) 44 | sys.exit(1) -------------------------------------------------------------------------------- /src/parse/EmployeeBioReader.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from sqlite3 import Connection, Cursor 3 | 4 | from fastapi import UploadFile 5 | from pandas import read_excel 6 | 7 | from src.db import Employee, EmployeeDepartment 8 | 9 | 10 | class EmployeeBioReader: 11 | def __init__(self, names: slice): 12 | self.names = names 13 | 14 | @staticmethod 15 | async def new(file: UploadFile): 16 | names = [] 17 | file_contents = await file.read() 18 | df = read_excel(BytesIO(file_contents)) 19 | for i, row in df.iterrows(): 20 | employee_status = row["Employee Status"] 21 | if employee_status == "Terminated": 22 | continue 23 | name = row["Employee Name"] 24 | names.append(name) 25 | return EmployeeBioReader(names) 26 | 27 | def insert_all_employees(self, conn: Connection, c: Cursor, cfa_location_id: str): 28 | current_employees = Employee.sqlite_find_all_by_cfa_location_id( 29 | c, cfa_location_id 30 | ) 31 | reader_names = self.names 32 | for name in reader_names: 33 | found = False 34 | for employee in current_employees: 35 | if name == employee.time_punch_name: 36 | found = True 37 | break 38 | if found == False: 39 | Employee.sqlite_insert_one(c, name, EmployeeDepartment.INIT, cfa_location_id) 40 | conn.commit() 41 | 42 | def remove_terminated_employees( 43 | self, conn: Connection, c: Cursor, cfa_location_id: str 44 | ): 45 | current_employees = Employee.sqlite_find_all_by_cfa_location_id( 46 | c, cfa_location_id 47 | ) 48 | for employee in current_employees: 49 | found = False 50 | for name in self.names: 51 | if name == employee.time_punch_name: 52 | found = True 53 | break 54 | if found == False: 55 | Employee.sqlite_delete_by_id(c, employee.id) 56 | conn.commit() 57 | -------------------------------------------------------------------------------- /src/db/Location.py: -------------------------------------------------------------------------------- 1 | from sqlite3 import Cursor 2 | 3 | 4 | class Location: 5 | def __init__(self, id, name, number): 6 | self.id = id 7 | self.name = name 8 | self.number = number 9 | 10 | @staticmethod 11 | def sqlite_create_table(c: Cursor): 12 | sql = """ 13 | CREATE TABLE IF NOT EXISTS cfa_locations ( 14 | id INTEGER PRIMARY KEY AUTOINCREMENT, 15 | name TEXT NOT NULL, 16 | number INTEGER NOT NULL 17 | ) 18 | """ 19 | c.execute(sql) 20 | 21 | @staticmethod 22 | def sqlite_insert_one( 23 | c: Cursor, 24 | name: str, 25 | number: str, 26 | ): 27 | sql = """INSERT INTO cfa_locations (name, number) VALUES (?, ?)""" 28 | params = ( 29 | name, 30 | number, 31 | ) 32 | c.execute(sql, params) 33 | return c.lastrowid 34 | 35 | @staticmethod 36 | def sqlite_delete_by_id(c: Cursor, id: str): 37 | sql = """DELETE FROM cfa_locations WHERE id = ?""" 38 | params = (id,) 39 | c.execute(sql, params) 40 | return c.rowcount 41 | 42 | @staticmethod 43 | def sqlite_find_by_id(c: Cursor, id: str): 44 | sql = f"""SELECT * FROM cfa_locations WHERE id = ?""" 45 | params = (id,) 46 | c.execute(sql, params) 47 | row = c.fetchone() 48 | if row == None: 49 | return None 50 | (id, name, number) = row 51 | return Location(id, name, number) 52 | 53 | @staticmethod 54 | def sqlite_find_by_number(c: Cursor, number: str): 55 | sql = "SELECT * FROM cfa_locations WHERE number = ?" 56 | params = (number,) 57 | c.execute(sql, params) 58 | row = c.fetchone() 59 | if row == None: 60 | return None 61 | (id, name, number) = row 62 | return Location(id, name, number) 63 | 64 | @staticmethod 65 | def sqlite_find_all(c: Cursor): 66 | sql = f"""SELECT * FROM cfa_locations""" 67 | params = () 68 | c.execute(sql, params) 69 | rows = c.fetchall() 70 | out = [] 71 | for row in rows: 72 | (id, name, number) = row 73 | out.append(Location(id, name, number)) 74 | return out 75 | -------------------------------------------------------------------------------- /birthday_bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import date 3 | 4 | from dotenv import load_dotenv 5 | import requests 6 | 7 | from src.db import * 8 | 9 | today = date.today() 10 | today_iso = today.isoformat() 11 | 12 | load_dotenv() 13 | 14 | # GROUPME_BOT_ID_TULSA_MIDTOWN_DIRECTORS = os.getenv('GROUPME_BOT_ID_TULSA_MIDTOWN_DIRECTORS') 15 | GROUPME_BOT_ID_TEST = os.getenv("GROUPME_BOT_ID_TEST") 16 | 17 | def birthday_message(employee: Employee): 18 | return f"BIRTHDAY DETECTED IN HR/PAYROLL\n{employee.name}" 19 | 20 | def message_groupme(msg: str, bot_ids: list[str]): 21 | url = "https://api.groupme.com/v3/bots/post" 22 | for bot_id in bot_ids: 23 | payload = { 24 | "text": msg, 25 | "bot_id": bot_id 26 | } 27 | try: 28 | requests.post(url, json=payload).raise_for_status() 29 | except requests.exceptions.RequestException as e: 30 | print(f"Failed to send to bot {bot_id}: {e}") 31 | 32 | 33 | def message_southroads(employee: Employee): 34 | msg = birthday_message(employee) 35 | message_groupme(msg, [GROUPME_BOT_ID_TEST]) 36 | 37 | 38 | def message_utica(employee: Employee): 39 | msg = birthday_message(employee) 40 | message_groupme(msg, [GROUPME_BOT_ID_TEST]) 41 | 42 | SQLITE_ABSOLUTE_PATH = os.getenv("SQLITE_ABSOLUTE_PATH") 43 | conn, c = sqlite_connection(SQLITE_ABSOLUTE_PATH) 44 | location_numbers = [3253, 5429] 45 | birthday_employees = [] 46 | for location_number in location_numbers: 47 | location = Location.sqlite_find_by_number(c, location_number) 48 | if location == None: 49 | continue 50 | employees = Employee.sqlite_find_all_by_cfa_location_id(c, location.id) 51 | for employee in employees: 52 | bday_parts = employee.birthday.split('-') 53 | if len(bday_parts) != 3: 54 | continue 55 | bday_month = bday_parts[1] 56 | bday_day = bday_parts[2] 57 | today_parts = today_iso.split('-') 58 | today_month = today_parts[1] 59 | today_day = today_parts[2] 60 | if today_day == bday_day and today_month == bday_month: 61 | birthday_employees.append([employee, location_number]) 62 | 63 | for group in birthday_employees: 64 | employee = group[0] 65 | location_number = group[1] 66 | if location_number == 3253: 67 | message_southroads(employee) 68 | if location_number == 5429: 69 | message_utica(employee) 70 | -------------------------------------------------------------------------------- /src/db/Session.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import string 3 | from datetime import datetime, timedelta 4 | from sqlite3 import Cursor 5 | 6 | 7 | class Session: 8 | def __init__(self, id, user_id, key, expires_at): 9 | self.id = id 10 | self.user_id = user_id 11 | self.key = key 12 | self.expires_at = expires_at 13 | 14 | @staticmethod 15 | def generate_key(): 16 | alpha = string.ascii_letters + string.digits 17 | return "".join(secrets.choice(alpha) for _ in range(16)) 18 | 19 | @staticmethod 20 | def generate_expiration(): 21 | return (datetime.now() + timedelta(minutes=30)).isoformat() 22 | 23 | @staticmethod 24 | def sqlite_create_table(c: Cursor): 25 | sql = """ 26 | CREATE TABLE IF NOT EXISTS sessions ( 27 | id INTEGER PRIMARY KEY AUTOINCREMENT, 28 | user_id INTEGER NOT NULL, 29 | key TEXT NOT NULL, 30 | expires_at TEXT NOT NULL 31 | ) 32 | """ 33 | c.execute(sql) 34 | 35 | @staticmethod 36 | def sqlite_insert(c: Cursor, user_id): 37 | key = Session.generate_key() 38 | expires_at = Session.generate_expiration() 39 | sql = """ 40 | INSERT INTO sessions (user_id, key, expires_at) 41 | VALUES (?, ?, ?) 42 | """ 43 | c.execute(sql, (user_id, key, expires_at)) 44 | return Session(c.lastrowid, user_id, key, expires_at) 45 | 46 | @staticmethod 47 | def sqlite_get_by_id(c: Cursor, id: int): 48 | sql = "SELECT * FROM sessions WHERE id = ?" 49 | c.execute(sql, (id,)) 50 | row = c.fetchone() 51 | if row: 52 | (id, user_id, key, expires_at) = row 53 | return Session(id, user_id, key, expires_at) 54 | return None 55 | 56 | def is_expired(self): 57 | return datetime.fromisoformat(self.expires_at) < datetime.now() 58 | 59 | @staticmethod 60 | def sqlite_get_by_user_id(c: Cursor, user_id: int): 61 | sql = "SELECT * FROM sessions WHERE user_id = ?" 62 | c.execute(sql, (user_id,)) 63 | row = c.fetchone() 64 | if row: 65 | (id, user_id, key, expires_at) = row 66 | return Session(id, user_id, key, expires_at) 67 | return None 68 | 69 | @staticmethod 70 | def sqlite_delete_by_id(c: Cursor, id: int): 71 | sql = "DELETE from sessions WHERE id = ?" 72 | c.execute(sql, (id,)) 73 | return c.rowcount 74 | 75 | @staticmethod 76 | def sqlite_delete_by_session_key(c: Cursor, key: str): 77 | sql = "DELETE FROM sessions WHERE key = ?" 78 | c.execute(sql, (key,)) 79 | return c.rowcount 80 | 81 | def __str__(self): 82 | return f""" 83 | id: {self.id} 84 | user_id: {self.user_id} 85 | key: {self.key} 86 | expires_at: {self.expires_at} 87 | """ 88 | -------------------------------------------------------------------------------- /templates/layout/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head %} 5 | 6 | 7 | {% block title %}{% endblock %} - CFA Suite 8 | 9 | 10 | 11 | 12 | 13 | 16 | {% endblock %} 17 | 18 | 19 | 39 | 40 |
41 |
42 |
43 | Admin Home 44 | Locations 45 |
46 |
47 |
48 | 49 |
50 |
51 | {% block content %}{% endblock %} 52 |
53 |
54 | 55 | -------------------------------------------------------------------------------- /templates/page/guest/home.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/guest.html" %} 2 | {% block title %}Login{% endblock %} 3 | {% block content %} 4 | 5 |
6 | 7 |
8 |

9 | CFA Suite 10 |

11 |

12 | Please sign in to continue 13 |

14 |
15 | 16 |
17 |
18 | 19 |
20 | 21 | {% if error %} 22 |
23 |
24 |
25 |

Login Failed

26 |
27 |

{{ error }}

28 |
29 |
30 |
31 |
32 | {% endif %} 33 | 34 |
35 | 36 |
37 | 45 |
46 |
47 | 48 |
49 | 50 |
51 | 59 |
60 |
61 | 62 |
63 | 66 |
67 |
68 | 69 |
70 |
71 |
72 | 73 | {% endblock %} -------------------------------------------------------------------------------- /src/bots/birthday_bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import date 3 | import requests 4 | from dotenv import load_dotenv 5 | 6 | from src.db import * 7 | 8 | load_dotenv() 9 | 10 | # --- Configuration & Env Vars --- 11 | GROUPME_BOT_ID_MIDTOWN_TULSA_DIRECTORS = os.getenv('GROUPME_BOT_ID_MIDTOWN_TULSA_DIRECTORS') 12 | GROUPME_BOT_ID_MIDTOWN_LEADERSHIP = os.getenv("GROUPME_BOT_ID_MIDTOWN_LEADERSHIP") 13 | SQLITE_ABSOLUTE_PATH = os.getenv("SQLITE_ABSOLUTE_PATH") 14 | 15 | # --- Helper Functions --- 16 | 17 | def birthday_message(employee: Employee): 18 | return f"BIRTHDAY DETECTED IN HR/PAYROLL\n{employee.name}" 19 | 20 | def message_groupme(employee: Employee): 21 | """ 22 | Sends a birthday message to the default leadership/director bots. 23 | """ 24 | msg = birthday_message(employee) 25 | bot_ids = [ 26 | GROUPME_BOT_ID_MIDTOWN_TULSA_DIRECTORS, 27 | GROUPME_BOT_ID_MIDTOWN_LEADERSHIP, 28 | ] 29 | url = "https://api.groupme.com/v3/bots/post" 30 | 31 | for bot_id in bot_ids: 32 | # Skip if env var is missing 33 | if not bot_id: 34 | continue 35 | 36 | payload = { 37 | "text": msg, 38 | "bot_id": bot_id 39 | } 40 | try: 41 | requests.post(url, json=payload).raise_for_status() 42 | print(f"Sent birthday message for {employee.name} to bot {bot_id}") 43 | except requests.exceptions.RequestException as e: 44 | print(f"Failed to send to bot {bot_id}: {e}") 45 | 46 | # --- Main Logic Function --- 47 | 48 | def process_daily_birthdays(): 49 | """ 50 | Connects to the DB, finds employees with birthdays today, 51 | and sends GroupMe messages based on location. 52 | """ 53 | # 1. Setup Date 54 | today = date.today() 55 | today_iso = today.isoformat() 56 | today_parts = today_iso.split('-') 57 | today_month = today_parts[1] 58 | today_day = today_parts[2] 59 | 60 | # 2. Database Connection 61 | if not SQLITE_ABSOLUTE_PATH: 62 | print("Error: SQLITE_ABSOLUTE_PATH not set in environment.") 63 | return 64 | 65 | conn, c = sqlite_connection(SQLITE_ABSOLUTE_PATH) 66 | 67 | location_numbers = [3253, 5429] 68 | birthday_employees = [] 69 | 70 | print(f"Checking for birthdays on {today_iso}...") 71 | 72 | # 3. Find Birthday Employees 73 | for location_number in location_numbers: 74 | location = Location.sqlite_find_by_number(c, location_number) 75 | if location is None: 76 | continue 77 | 78 | employees = Employee.sqlite_find_all_by_cfa_location_id(c, location.id) 79 | 80 | for employee in employees: 81 | if not employee.birthday: 82 | continue 83 | 84 | bday_parts = employee.birthday.split('-') 85 | if len(bday_parts) != 3: 86 | continue 87 | 88 | bday_month = bday_parts[1] 89 | bday_day = bday_parts[2] 90 | 91 | if today_day == bday_day and today_month == bday_month: 92 | birthday_employees.append([employee, location_number]) 93 | 94 | # 4. Send Messages 95 | if not birthday_employees: 96 | print("No birthdays found today.") 97 | 98 | messaged_names = [] 99 | 100 | for group in birthday_employees: 101 | employee = group[0] 102 | location_number = group[1] 103 | 104 | if employee.name in messaged_names: 105 | continue 106 | messaged_names.append(employee.name) 107 | 108 | message_groupme(employee) 109 | 110 | # Close connection (Good practice) 111 | conn.close() -------------------------------------------------------------------------------- /templates/page/admin/locations.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/admin.html" %} 2 | {% block title %}Locations{% endblock %} 3 | {% block content %} 4 | 5 |
6 | 7 |
8 |

Locations

9 |
10 | 11 |
12 |
13 |

Add New Location

14 | {% if err_create_location %} 15 |
16 |
17 |
18 |

Error

19 |
20 |

{{ err_create_location }}

21 |
22 |
23 |
24 |
25 | {% endif %} 26 | 27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 | 38 | 41 |
42 |
43 |
44 |
45 | 46 |
47 | 67 |
68 |
69 | 70 | {% endblock %} -------------------------------------------------------------------------------- /src/parse/HotSchedulesStaffReader.py: -------------------------------------------------------------------------------- 1 | from sqlite3 import Cursor 2 | 3 | from bs4 import BeautifulSoup 4 | 5 | from ..db import Employee, EmployeeDepartment 6 | 7 | 8 | class HotSchedulesStaffReader: 9 | def __init__(self): 10 | return 11 | 12 | @staticmethod 13 | async def new(c: Cursor, html: str, employees: list[Employee]): 14 | soup = BeautifulSoup(html, "html.parser") 15 | staff_table = soup.find(id="stafftable") 16 | rows = staff_table.find_all("tr") 17 | for employee in employees: 18 | for row in rows: 19 | cols = row.find_all("td") 20 | if len(cols) < 7: 21 | continue 22 | employee_name = cols[1].text.strip() 23 | if employee.name == employee_name: 24 | employee_jobs = cols[6] 25 | 26 | # here is where we set employee departments 27 | # departments are based on precedence 28 | # precedence order is listed here 29 | # order is least important to most important 30 | # on the righthanded side of the "-" (next to the departments below) 31 | # this represents the job code in hotschedules which maps to the listed department 32 | # INIT - should not have any init, if we do, we failed to set a job code in hotschedules 33 | # NONE - a single '-' will be in the job code cell for the employee if they are not working at the store 34 | # TRAINING - 'In Training' 35 | # FOH - 'FOH' 36 | # BOH - 'BOH' 37 | # RLT - 'Front Counter Stager' 38 | # CST - 'Lemons' 39 | # EXECUTIVE - 'Mobile Drinks' 40 | # PARTNER - 'Dispatcher' 41 | 42 | depeartment = EmployeeDepartment.INIT 43 | if employee_jobs.text == "-": 44 | depeartment = EmployeeDepartment.NONE 45 | else: 46 | tooltip_attr = employee_jobs.find("span").get("tooltip") 47 | # single word attrs 48 | if tooltip_attr == None: 49 | job = employee_jobs.text 50 | if 'In Training' in job: 51 | depeartment = EmployeeDepartment.TRAINING 52 | if "FOH" in job: 53 | depeartment = EmployeeDepartment.FOH 54 | if "BOH" in job: 55 | depeartment = EmployeeDepartment.BOH 56 | if 'Front Counter Stager' in job: 57 | depeartment = EmployeeDepartment.RLT 58 | if 'Lemons' in job: 59 | depeartment = EmployeeDepartment.CST 60 | if 'Mobile Drinks' in job: 61 | depeartment = EmployeeDepartment.EXECUTIVE 62 | if 'Dispatcher' in job: 63 | depeartment = EmployeeDepartment.PARTNER 64 | if depeartment == "": 65 | depeartment = EmployeeDepartment.INIT 66 | # jobs found in the tooltip attr 67 | else: 68 | if 'In Training' in tooltip_attr: 69 | depeartment = EmployeeDepartment.TRAINING 70 | if "FOH" in tooltip_attr: 71 | depeartment = EmployeeDepartment.FOH 72 | if "BOH" in tooltip_attr: 73 | depeartment = EmployeeDepartment.BOH 74 | if 'Front Counter Stager' in tooltip_attr: 75 | depeartment = EmployeeDepartment.RLT 76 | if 'Lemons' in tooltip_attr: 77 | depeartment = EmployeeDepartment.CST 78 | if 'Mobile Drinks' in tooltip_attr: 79 | depeartment = EmployeeDepartment.EXECUTIVE 80 | if 'Dispatcher' in tooltip_attr: 81 | depeartment = EmployeeDepartment.PARTNER 82 | if depeartment == "": 83 | depeartment = EmployeeDepartment.INIT 84 | Employee.sqlite_update_department(c, employee.id, depeartment) 85 | 86 | # print(employee_name, "\n", depeartment) 87 | # print(rows) 88 | -------------------------------------------------------------------------------- /src/db/Employee.py: -------------------------------------------------------------------------------- 1 | from sqlite3 import Cursor 2 | from enum import StrEnum 3 | 4 | 5 | class EmployeeDepartment(StrEnum): 6 | INIT = 'INIT' 7 | FOH = 'FOH' 8 | BOH = 'BOH' 9 | TRAINING= 'TRAINING' 10 | RLT = 'RLT' 11 | CST = 'CST' 12 | EXECUTIVE = 'EXECUTIVE' 13 | PARTNER = 'PARTNER' 14 | NONE = 'NONE' 15 | 16 | 17 | class Employee: 18 | def __init__( 19 | self, id, cfa_location_id, time_punch_name, name, department, birthday="" 20 | ): 21 | self.id = id 22 | self.cfa_location_id = cfa_location_id 23 | self.time_punch_name = time_punch_name 24 | self.name = name 25 | self.department = department 26 | self.birthday = birthday 27 | 28 | @staticmethod 29 | def sqlite_add_birthday(c: Cursor, id: str, birthday: str): 30 | sql = "UPDATE employees SET birthday = ? WHERE id = ?" 31 | params = (birthday, id) 32 | c.execute(sql, params) 33 | return c.rowcount 34 | 35 | @staticmethod 36 | def sqlite_create_table(c: Cursor): 37 | sql = """ 38 | CREATE TABLE IF NOT EXISTS employees ( 39 | id INTEGER PRIMARY KEY AUTOINCREMENT, 40 | cfa_location_id INTEGER NOT NULL, 41 | time_punch_name TEXT NOT NULL, 42 | name TEXT NOT NULL, 43 | department TEXT NOT NULL, 44 | birthday TEXT 45 | ) 46 | """ 47 | c.execute(sql) 48 | 49 | @staticmethod 50 | def sqlite_insert_one( 51 | c: Cursor, 52 | time_punch_name: str, 53 | department: str, 54 | cfa_location_id: str, 55 | birthday="", 56 | ): 57 | name_parts = time_punch_name.split(",") 58 | last_name = name_parts[0].removesuffix(" ") 59 | first_name = name_parts[1] 60 | first_name = first_name.removeprefix(" ") 61 | first_name_finalized = "" 62 | if first_name.count(" ") > 0: 63 | first_name_finalized += ( 64 | first_name.split(" ")[0].removesuffix(" ").removeprefix(" ") 65 | ) 66 | else: 67 | first_name_finalized += first_name.removesuffix(" ").removeprefix(" ") 68 | name = f"{first_name_finalized} {last_name}" 69 | sql = f"""INSERT INTO employees (name, time_punch_name, department, cfa_location_id, birthday) VALUES (?, ?, ?, ?, ?)""" 70 | params = ( 71 | name, 72 | time_punch_name, 73 | department, 74 | cfa_location_id, 75 | birthday, 76 | ) 77 | c.execute(sql, params) 78 | return Employee(c.lastrowid, cfa_location_id, time_punch_name, name, department) 79 | 80 | @staticmethod 81 | def sqlite_find_by_time_punch_name(c: Cursor, time_punch_name: str): 82 | sql = f"""SELECT * FROM employees WHERE time_punch_name = ?""" 83 | params = (time_punch_name,) 84 | c.execute(sql, params) 85 | row = c.fetchone() 86 | if row == None: 87 | return None 88 | (id, cfa_location_id, time_punch_name, name, department) = row 89 | return Employee(id, cfa_location_id, time_punch_name, name, department) 90 | 91 | @staticmethod 92 | def sqlite_find_by_id(c: Cursor, id: str): 93 | sql = f"""SELECT * FROM employees WHERE id = ?""" 94 | params = (id,) 95 | c.execute(sql, params) 96 | row = c.fetchone() 97 | if row == None: 98 | return None 99 | (id, cfa_location_id, time_punch_name, name, department) = row 100 | return Employee(id, cfa_location_id, time_punch_name, name, department) 101 | 102 | @staticmethod 103 | def sqlite_find_all_by_cfa_location_id(c: Cursor, cfa_location_id: str): 104 | sql = f"""SELECT * FROM employees WHERE cfa_location_id = ? ORDER BY time_punch_name ASC""" 105 | params = (cfa_location_id,) 106 | c.execute(sql, params) 107 | rows = c.fetchall() 108 | out = [] 109 | for row in rows: 110 | (id, cfa_location_id, time_punch_name, name, department, birthday) = row 111 | out.append( 112 | Employee( 113 | id, cfa_location_id, time_punch_name, name, department, birthday 114 | ) 115 | ) 116 | return out 117 | 118 | @staticmethod 119 | def sqlite_delete_by_id(c: Cursor, id: str): 120 | sql = "DELETE FROM employees WHERE id = ?" 121 | params = (id,) 122 | c.execute(sql, params) 123 | return c.rowcount 124 | 125 | @staticmethod 126 | def sqlite_update_department(c: Cursor, id: str, department: str): 127 | sql = "UPDATE employees SET department = ? WHERE id = ?" 128 | params = (department, id) 129 | c.execute(sql, params) 130 | return c.rowcount 131 | -------------------------------------------------------------------------------- /src/parse/TimePunchReader.py: -------------------------------------------------------------------------------- 1 | import json 2 | from decimal import ROUND_HALF_UP, Decimal 3 | from io import BytesIO 4 | from urllib.parse import quote 5 | 6 | from PyPDF2 import PdfReader 7 | 8 | 9 | class TimePunchEmployee: 10 | def __init__( 11 | self, 12 | name, 13 | total_time, 14 | regular_hours, 15 | regular_wages, 16 | overtime_hours, 17 | overtime_wages, 18 | total_wages, 19 | ): 20 | self.name = name 21 | self.total_time = total_time 22 | self.regular_hours = regular_hours 23 | self.regular_wages = Decimal(regular_wages) 24 | self.overtime_hours = overtime_hours 25 | self.overtime_wages = Decimal(overtime_wages) 26 | self.total_wages = Decimal(total_wages) 27 | 28 | 29 | class TimePunchReader: 30 | def __init__(self, time_punch_file: bytes, current_employees: list): 31 | self.time_punch_bytes = BytesIO(time_punch_file) 32 | 33 | self.current_employees = current_employees 34 | self.time_punch_employees = [] 35 | 36 | self.text = "" 37 | 38 | # Cost buckets 39 | self.term_cost = Decimal(0) 40 | self.boh_cost = Decimal(0) 41 | self.cst_cost = Decimal(0) 42 | self.rlt_cost = Decimal(0) 43 | self.foh_cost = Decimal(0) 44 | self.training_cost = Decimal(0) 45 | self.executive_cost = Decimal(0) 46 | self.partner_cost = Decimal(0) 47 | 48 | # Percentage buckets 49 | self.term_percentage = 0 50 | self.boh_percentage = 0 51 | self.cst_percentage = 0 52 | self.rlt_percentage = 0 53 | self.foh_percentage = 0 54 | self.training_percentage = 0 55 | self.executive_percentage = 0 56 | self.partner_percentage = 0 57 | 58 | self.total_hours = 0 59 | self.regular_hours = 0 60 | self.overtime_hours = 0 61 | 62 | self.regular_wages = Decimal(0) 63 | self.overtime_wages = Decimal(0) 64 | self.total_wages = Decimal(0) 65 | 66 | self.init_pdf_totals() 67 | self.init_department_totals() 68 | 69 | def init_pdf_totals(self): 70 | reader = PdfReader(self.time_punch_bytes) 71 | for page in reader.pages: 72 | page_text = page.extract_text() 73 | if page_text: 74 | self.text += page_text 75 | 76 | lines = self.text.split("\n") 77 | trim = [] 78 | for line in lines: 79 | if "," in line: 80 | if "From " in line: 81 | continue 82 | if "Mon," in line: 83 | continue 84 | if "Tue," in line: 85 | continue 86 | if "Wed," in line: 87 | continue 88 | if "Thu," in line: 89 | continue 90 | if "Fri," in line: 91 | continue 92 | if "Sat," in line: 93 | continue 94 | if "Sun," in line: 95 | continue 96 | if ( 97 | 'Punch types of "Break (Conv to Paid)" were created by Time Punch due to an unpaid break that did not meet the Minimum Unpaid Break Duration setting.' 98 | in line 99 | ): 100 | line = line.replace( 101 | 'Punch types of "Break (Conv to Paid)" were created by Time Punch due to an unpaid break that did not meet the Minimum Unpaid Break Duration setting.', 102 | "", 103 | ) 104 | trim.append(line) 105 | continue 106 | trim.append(line) 107 | continue 108 | if ":" in line: 109 | if ( 110 | 'Punch types of "Break (Conv to Paid)" were created by Time Punch due to an unpaid break that did not meet the Minimum Unpaid Break Duration setting.' 111 | in line 112 | ): 113 | line = line.replace( 114 | 'Punch types of "Break (Conv to Paid)" were created by Time Punch due to an unpaid break that did not meet the Minimum Unpaid Break Duration setting.', 115 | "", 116 | ) 117 | trim.append(line) 118 | continue 119 | if "AM" in line or "PM" in line or " am " in line or " pm " in line: 120 | continue 121 | trim.append(line) 122 | continue 123 | filtered_again = [] 124 | for line in trim: 125 | if line.startswith("Punch"): 126 | continue 127 | filtered_again.append(line) 128 | grand_total_line = filtered_again.pop() 129 | names = [] 130 | totals = [] 131 | toggle = 0 132 | for line in filtered_again: 133 | if toggle == 0: 134 | names.append(line) 135 | toggle = 1 136 | continue 137 | if toggle == 1: 138 | totals.append(line) 139 | toggle = 0 140 | continue 141 | for i, name in enumerate(names): 142 | total = totals[i] 143 | total_trimmed = total.replace("Employee Totals ", "") 144 | parts = total_trimmed.split(" ") 145 | time_punch_employee = TimePunchEmployee( 146 | name, 147 | parts[0], 148 | parts[1], 149 | Decimal(parts[2].replace("$", "").replace(",", "")), 150 | parts[3], 151 | Decimal(parts[4].replace("$", "").replace(",", "")), 152 | Decimal(parts[5].replace("$", "").replace(",", "")), 153 | ) 154 | self.time_punch_employees.append(time_punch_employee) 155 | grand_total_line = grand_total_line.replace("All Employees Grand Total ", "") 156 | grand_total_parts = grand_total_line.split(" ") 157 | self.total_hours = grand_total_parts[0] 158 | self.regular_hours = grand_total_parts[1] 159 | self.regular_wages = Decimal( 160 | grand_total_parts[2].replace("$", "").replace(",", "") 161 | ) 162 | self.overtime_hours = grand_total_parts[3] 163 | self.overtime_wages = Decimal( 164 | grand_total_parts[4].replace("$", "").replace(",", "") 165 | ) 166 | self.total_wages = Decimal( 167 | grand_total_parts[5].replace("$", "").replace(",", "") 168 | ) 169 | 170 | def init_department_totals(self): 171 | for time_punch_employee in self.time_punch_employees: 172 | found = False 173 | for current_employee in self.current_employees: 174 | if time_punch_employee.name == current_employee.time_punch_name: 175 | found = True 176 | # Check for specific departments (excluding NONE) 177 | if current_employee.department == "BOH": 178 | self.boh_cost += time_punch_employee.total_wages 179 | elif current_employee.department == "FOH": 180 | self.foh_cost += time_punch_employee.total_wages 181 | elif current_employee.department == "CST": 182 | self.cst_cost += time_punch_employee.total_wages 183 | elif current_employee.department == "RLT": 184 | self.rlt_cost += time_punch_employee.total_wages 185 | elif current_employee.department == "TRAINING": 186 | self.training_cost += time_punch_employee.total_wages 187 | elif current_employee.department == "EXECUTIVE": 188 | self.executive_cost += time_punch_employee.total_wages 189 | elif current_employee.department == "PARTNER": 190 | self.partner_cost += time_punch_employee.total_wages 191 | break 192 | 193 | # Logic for Terminated/Unmatched employees 194 | if found == False: 195 | self.term_cost += time_punch_employee.total_wages 196 | 197 | # Calculate Percentages 198 | if self.total_wages > 0: 199 | self.term_percentage = ((self.term_cost * 100) / self.total_wages).quantize( 200 | Decimal("0.01"), rounding=ROUND_HALF_UP 201 | ) 202 | self.foh_percentage = ((self.foh_cost * 100) / self.total_wages).quantize( 203 | Decimal("0.01"), rounding=ROUND_HALF_UP 204 | ) 205 | self.rlt_percentage = ((self.rlt_cost * 100) / self.total_wages).quantize( 206 | Decimal("0.01"), rounding=ROUND_HALF_UP 207 | ) 208 | self.cst_percentage = ((self.cst_cost * 100) / self.total_wages).quantize( 209 | Decimal("0.01"), rounding=ROUND_HALF_UP 210 | ) 211 | self.boh_percentage = ((self.boh_cost * 100) / self.total_wages).quantize( 212 | Decimal("0.01"), rounding=ROUND_HALF_UP 213 | ) 214 | self.training_percentage = ((self.training_cost * 100) / self.total_wages).quantize( 215 | Decimal("0.01"), rounding=ROUND_HALF_UP 216 | ) 217 | self.executive_percentage = ((self.executive_cost * 100) / self.total_wages).quantize( 218 | Decimal("0.01"), rounding=ROUND_HALF_UP 219 | ) 220 | self.partner_percentage = ((self.partner_cost * 100) / self.total_wages).quantize( 221 | Decimal("0.01"), rounding=ROUND_HALF_UP 222 | ) 223 | 224 | def __str__(self): 225 | lines = [ 226 | f"Total Hours: {self.total_hours}", 227 | f"Regular Hours: {self.regular_hours}, Regular Wages: ${self.regular_wages}", 228 | f"Overtime Hours: {self.overtime_hours}, Overtime Wages: ${self.overtime_wages}", 229 | f"Total Wages: ${self.total_wages}", 230 | "", 231 | "Department Costs:", 232 | f" BOH: ${self.boh_cost} ({self.boh_percentage}%)", 233 | f" FOH: ${self.foh_cost} ({self.foh_percentage}%)", 234 | f" CST: ${self.cst_cost} ({self.cst_percentage}%)", 235 | f" RLT: ${self.rlt_cost} ({self.rlt_percentage}%)", 236 | f" TRAINING: ${self.training_cost} ({self.training_percentage}%)", 237 | f" EXECUTIVE: ${self.executive_cost} ({self.executive_percentage}%)", 238 | f" PARTNER: ${self.partner_cost} ({self.partner_percentage}%)", 239 | "", 240 | "Employees:", 241 | ] 242 | return "\n".join(lines) 243 | 244 | def to_json(self): 245 | dict = { 246 | "boh_cost": float(self.boh_cost), 247 | "cst_cost": float(self.cst_cost), 248 | "rlt_cost": float(self.rlt_cost), 249 | "foh_cost": float(self.foh_cost), 250 | "term_cost": float(self.term_cost), 251 | "training_cost": float(self.training_cost), 252 | "executive_cost": float(self.executive_cost), 253 | "partner_cost": float(self.partner_cost), 254 | 255 | "boh_percentage": float(self.boh_percentage), 256 | "cst_percentage": float(self.cst_percentage), 257 | "rlt_percentage": float(self.rlt_percentage), 258 | "foh_percentage": float(self.foh_percentage), 259 | "term_percentage": float(self.term_percentage), 260 | "training_percentage": float(self.training_percentage), 261 | "executive_percentage": float(self.executive_percentage), 262 | "partner_percentage": float(self.partner_percentage), 263 | 264 | "total_hours": str(self.total_hours), 265 | "regular_hours": str(self.regular_hours), 266 | "overtime_hours": str(self.overtime_hours), 267 | "regular_wages": float(self.regular_wages), 268 | "overtime_wages": float(self.overtime_wages), 269 | "total_wages": float(self.total_wages), 270 | } 271 | json_str = json.dumps(dict) 272 | safe_json = quote(json_str) 273 | return safe_json -------------------------------------------------------------------------------- /templates/page/admin/location.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/admin.html" %} 2 | {% block title %}Locations{% endblock %} 3 | {% block content %} 4 | 5 |
6 | 7 |
8 |

9 | #{{ cfa_location.number }} {{ cfa_location.name }} 10 |

11 |
12 | 13 |
14 | 15 |
16 |
17 |

Upload Time Punch

18 |
19 | 20 | 21 | 22 | 23 |
24 |
25 |
26 | 27 |
28 |
29 |

Upload HotSchedules

30 |
31 | 32 | 33 | 34 | 35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 |

Birthday Report

43 |
44 | 45 | 46 | 47 | 48 |
49 |
50 |
51 |

Employee Bio

52 |
53 | 54 | 55 | 56 | 57 |
58 |
59 |
60 |
61 |
62 | 63 | {% if time_punch_data %} 64 |
65 |
66 |
67 |

Total Breakdown

68 |
69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 |
CategoryHoursWages
Regular{{ time_punch_data['regular_hours'] }}{{ time_punch_data['regular_wages'] }}
Overtime{{ time_punch_data['overtime_hours'] }}{{ time_punch_data['overtime_wages'] }}
Total{{ time_punch_data['total_hours'] }}{{ time_punch_data['total_wages'] }}
95 |
96 | 97 |
98 |
99 |

Department Breakdown

100 |
101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | {% for dept in [ 111 | ('FOH', 'foh'), 112 | ('BOH', 'boh'), 113 | ('RLT', 'rlt'), 114 | ('CST', 'cst'), 115 | ('TRAINING', 'training'), 116 | ('EXECUTIVE', 'executive'), 117 | ('PARTNER', 'partner'), 118 | ('TERM', 'term') 119 | ] %} 120 | 121 | 122 | 123 | 124 | 125 | {% endfor %} 126 | 127 |
DeptPercentageCost
{{ dept[0] }}{{ time_punch_data[dept[1] + '_percentage'] }}{{ time_punch_data[dept[1] + '_cost'] }}
128 |
129 |
130 | {% endif %} 131 | 132 |
133 |
134 |

Employee List

135 |
136 |
137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | {% for employee in employees %} 149 | 150 | 153 | 159 | 162 | 178 | 186 | 187 | {% endfor %} 188 | 189 |
NameTime Punch NameBirthdayDepartmentAction
151 | {{ employee.name }} 152 | 154 | {{ employee.time_punch_name }} 155 | {% if employee.department == 'INIT' %} 156 | * 157 | {% endif %} 158 | 160 | {{ employee.birthday }} 161 | 163 |
164 | 172 | 173 | 174 | 175 | 176 |
177 |
179 | 185 |
190 |
191 |
192 | 193 |
194 |

Danger Zone

195 |
196 |

Delete CFA Location

197 |

This action cannot be undone. Please type the location number to confirm.

198 |
199 |
200 | 201 | 202 |
203 | 204 | 207 |
208 |
209 |
210 | 211 |
212 | 213 | 229 | 230 | {% endblock %} -------------------------------------------------------------------------------- /static/output.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v4.0.0 | MIT License | https://tailwindcss.com */ 2 | @layer theme, base, components, utilities; 3 | @layer theme { 4 | :root { 5 | --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 6 | 'Segoe UI Symbol', 'Noto Color Emoji'; 7 | --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; 8 | --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 9 | 'Courier New', monospace; 10 | --color-red-50: oklch(0.971 0.013 17.38); 11 | --color-red-100: oklch(0.936 0.032 17.717); 12 | --color-red-200: oklch(0.885 0.062 18.334); 13 | --color-red-300: oklch(0.808 0.114 19.571); 14 | --color-red-400: oklch(0.704 0.191 22.216); 15 | --color-red-500: oklch(0.637 0.237 25.331); 16 | --color-red-600: oklch(0.577 0.245 27.325); 17 | --color-red-700: oklch(0.505 0.213 27.518); 18 | --color-red-800: oklch(0.444 0.177 26.899); 19 | --color-red-900: oklch(0.396 0.141 25.723); 20 | --color-red-950: oklch(0.258 0.092 26.042); 21 | --color-orange-50: oklch(0.98 0.016 73.684); 22 | --color-orange-100: oklch(0.954 0.038 75.164); 23 | --color-orange-200: oklch(0.901 0.076 70.697); 24 | --color-orange-300: oklch(0.837 0.128 66.29); 25 | --color-orange-400: oklch(0.75 0.183 55.934); 26 | --color-orange-500: oklch(0.705 0.213 47.604); 27 | --color-orange-600: oklch(0.646 0.222 41.116); 28 | --color-orange-700: oklch(0.553 0.195 38.402); 29 | --color-orange-800: oklch(0.47 0.157 37.304); 30 | --color-orange-900: oklch(0.408 0.123 38.172); 31 | --color-orange-950: oklch(0.266 0.079 36.259); 32 | --color-amber-50: oklch(0.987 0.022 95.277); 33 | --color-amber-100: oklch(0.962 0.059 95.617); 34 | --color-amber-200: oklch(0.924 0.12 95.746); 35 | --color-amber-300: oklch(0.879 0.169 91.605); 36 | --color-amber-400: oklch(0.828 0.189 84.429); 37 | --color-amber-500: oklch(0.769 0.188 70.08); 38 | --color-amber-600: oklch(0.666 0.179 58.318); 39 | --color-amber-700: oklch(0.555 0.163 48.998); 40 | --color-amber-800: oklch(0.473 0.137 46.201); 41 | --color-amber-900: oklch(0.414 0.112 45.904); 42 | --color-amber-950: oklch(0.279 0.077 45.635); 43 | --color-yellow-50: oklch(0.987 0.026 102.212); 44 | --color-yellow-100: oklch(0.973 0.071 103.193); 45 | --color-yellow-200: oklch(0.945 0.129 101.54); 46 | --color-yellow-300: oklch(0.905 0.182 98.111); 47 | --color-yellow-400: oklch(0.852 0.199 91.936); 48 | --color-yellow-500: oklch(0.795 0.184 86.047); 49 | --color-yellow-600: oklch(0.681 0.162 75.834); 50 | --color-yellow-700: oklch(0.554 0.135 66.442); 51 | --color-yellow-800: oklch(0.476 0.114 61.907); 52 | --color-yellow-900: oklch(0.421 0.095 57.708); 53 | --color-yellow-950: oklch(0.286 0.066 53.813); 54 | --color-lime-50: oklch(0.986 0.031 120.757); 55 | --color-lime-100: oklch(0.967 0.067 122.328); 56 | --color-lime-200: oklch(0.938 0.127 124.321); 57 | --color-lime-300: oklch(0.897 0.196 126.665); 58 | --color-lime-400: oklch(0.841 0.238 128.85); 59 | --color-lime-500: oklch(0.768 0.233 130.85); 60 | --color-lime-600: oklch(0.648 0.2 131.684); 61 | --color-lime-700: oklch(0.532 0.157 131.589); 62 | --color-lime-800: oklch(0.453 0.124 130.933); 63 | --color-lime-900: oklch(0.405 0.101 131.063); 64 | --color-lime-950: oklch(0.274 0.072 132.109); 65 | --color-green-50: oklch(0.982 0.018 155.826); 66 | --color-green-100: oklch(0.962 0.044 156.743); 67 | --color-green-200: oklch(0.925 0.084 155.995); 68 | --color-green-300: oklch(0.871 0.15 154.449); 69 | --color-green-400: oklch(0.792 0.209 151.711); 70 | --color-green-500: oklch(0.723 0.219 149.579); 71 | --color-green-600: oklch(0.627 0.194 149.214); 72 | --color-green-700: oklch(0.527 0.154 150.069); 73 | --color-green-800: oklch(0.448 0.119 151.328); 74 | --color-green-900: oklch(0.393 0.095 152.535); 75 | --color-green-950: oklch(0.266 0.065 152.934); 76 | --color-emerald-50: oklch(0.979 0.021 166.113); 77 | --color-emerald-100: oklch(0.95 0.052 163.051); 78 | --color-emerald-200: oklch(0.905 0.093 164.15); 79 | --color-emerald-300: oklch(0.845 0.143 164.978); 80 | --color-emerald-400: oklch(0.765 0.177 163.223); 81 | --color-emerald-500: oklch(0.696 0.17 162.48); 82 | --color-emerald-600: oklch(0.596 0.145 163.225); 83 | --color-emerald-700: oklch(0.508 0.118 165.612); 84 | --color-emerald-800: oklch(0.432 0.095 166.913); 85 | --color-emerald-900: oklch(0.378 0.077 168.94); 86 | --color-emerald-950: oklch(0.262 0.051 172.552); 87 | --color-teal-50: oklch(0.984 0.014 180.72); 88 | --color-teal-100: oklch(0.953 0.051 180.801); 89 | --color-teal-200: oklch(0.91 0.096 180.426); 90 | --color-teal-300: oklch(0.855 0.138 181.071); 91 | --color-teal-400: oklch(0.777 0.152 181.912); 92 | --color-teal-500: oklch(0.704 0.14 182.503); 93 | --color-teal-600: oklch(0.6 0.118 184.704); 94 | --color-teal-700: oklch(0.511 0.096 186.391); 95 | --color-teal-800: oklch(0.437 0.078 188.216); 96 | --color-teal-900: oklch(0.386 0.063 188.416); 97 | --color-teal-950: oklch(0.277 0.046 192.524); 98 | --color-cyan-50: oklch(0.984 0.019 200.873); 99 | --color-cyan-100: oklch(0.956 0.045 203.388); 100 | --color-cyan-200: oklch(0.917 0.08 205.041); 101 | --color-cyan-300: oklch(0.865 0.127 207.078); 102 | --color-cyan-400: oklch(0.789 0.154 211.53); 103 | --color-cyan-500: oklch(0.715 0.143 215.221); 104 | --color-cyan-600: oklch(0.609 0.126 221.723); 105 | --color-cyan-700: oklch(0.52 0.105 223.128); 106 | --color-cyan-800: oklch(0.45 0.085 224.283); 107 | --color-cyan-900: oklch(0.398 0.07 227.392); 108 | --color-cyan-950: oklch(0.302 0.056 229.695); 109 | --color-sky-50: oklch(0.977 0.013 236.62); 110 | --color-sky-100: oklch(0.951 0.026 236.824); 111 | --color-sky-200: oklch(0.901 0.058 230.902); 112 | --color-sky-300: oklch(0.828 0.111 230.318); 113 | --color-sky-400: oklch(0.746 0.16 232.661); 114 | --color-sky-500: oklch(0.685 0.169 237.323); 115 | --color-sky-600: oklch(0.588 0.158 241.966); 116 | --color-sky-700: oklch(0.5 0.134 242.749); 117 | --color-sky-800: oklch(0.443 0.11 240.79); 118 | --color-sky-900: oklch(0.391 0.09 240.876); 119 | --color-sky-950: oklch(0.293 0.066 243.157); 120 | --color-blue-50: oklch(0.97 0.014 254.604); 121 | --color-blue-100: oklch(0.932 0.032 255.585); 122 | --color-blue-200: oklch(0.882 0.059 254.128); 123 | --color-blue-300: oklch(0.809 0.105 251.813); 124 | --color-blue-400: oklch(0.707 0.165 254.624); 125 | --color-blue-500: oklch(0.623 0.214 259.815); 126 | --color-blue-600: oklch(0.546 0.245 262.881); 127 | --color-blue-700: oklch(0.488 0.243 264.376); 128 | --color-blue-800: oklch(0.424 0.199 265.638); 129 | --color-blue-900: oklch(0.379 0.146 265.522); 130 | --color-blue-950: oklch(0.282 0.091 267.935); 131 | --color-indigo-50: oklch(0.962 0.018 272.314); 132 | --color-indigo-100: oklch(0.93 0.034 272.788); 133 | --color-indigo-200: oklch(0.87 0.065 274.039); 134 | --color-indigo-300: oklch(0.785 0.115 274.713); 135 | --color-indigo-400: oklch(0.673 0.182 276.935); 136 | --color-indigo-500: oklch(0.585 0.233 277.117); 137 | --color-indigo-600: oklch(0.511 0.262 276.966); 138 | --color-indigo-700: oklch(0.457 0.24 277.023); 139 | --color-indigo-800: oklch(0.398 0.195 277.366); 140 | --color-indigo-900: oklch(0.359 0.144 278.697); 141 | --color-indigo-950: oklch(0.257 0.09 281.288); 142 | --color-violet-50: oklch(0.969 0.016 293.756); 143 | --color-violet-100: oklch(0.943 0.029 294.588); 144 | --color-violet-200: oklch(0.894 0.057 293.283); 145 | --color-violet-300: oklch(0.811 0.111 293.571); 146 | --color-violet-400: oklch(0.702 0.183 293.541); 147 | --color-violet-500: oklch(0.606 0.25 292.717); 148 | --color-violet-600: oklch(0.541 0.281 293.009); 149 | --color-violet-700: oklch(0.491 0.27 292.581); 150 | --color-violet-800: oklch(0.432 0.232 292.759); 151 | --color-violet-900: oklch(0.38 0.189 293.745); 152 | --color-violet-950: oklch(0.283 0.141 291.089); 153 | --color-purple-50: oklch(0.977 0.014 308.299); 154 | --color-purple-100: oklch(0.946 0.033 307.174); 155 | --color-purple-200: oklch(0.902 0.063 306.703); 156 | --color-purple-300: oklch(0.827 0.119 306.383); 157 | --color-purple-400: oklch(0.714 0.203 305.504); 158 | --color-purple-500: oklch(0.627 0.265 303.9); 159 | --color-purple-600: oklch(0.558 0.288 302.321); 160 | --color-purple-700: oklch(0.496 0.265 301.924); 161 | --color-purple-800: oklch(0.438 0.218 303.724); 162 | --color-purple-900: oklch(0.381 0.176 304.987); 163 | --color-purple-950: oklch(0.291 0.149 302.717); 164 | --color-fuchsia-50: oklch(0.977 0.017 320.058); 165 | --color-fuchsia-100: oklch(0.952 0.037 318.852); 166 | --color-fuchsia-200: oklch(0.903 0.076 319.62); 167 | --color-fuchsia-300: oklch(0.833 0.145 321.434); 168 | --color-fuchsia-400: oklch(0.74 0.238 322.16); 169 | --color-fuchsia-500: oklch(0.667 0.295 322.15); 170 | --color-fuchsia-600: oklch(0.591 0.293 322.896); 171 | --color-fuchsia-700: oklch(0.518 0.253 323.949); 172 | --color-fuchsia-800: oklch(0.452 0.211 324.591); 173 | --color-fuchsia-900: oklch(0.401 0.17 325.612); 174 | --color-fuchsia-950: oklch(0.293 0.136 325.661); 175 | --color-pink-50: oklch(0.971 0.014 343.198); 176 | --color-pink-100: oklch(0.948 0.028 342.258); 177 | --color-pink-200: oklch(0.899 0.061 343.231); 178 | --color-pink-300: oklch(0.823 0.12 346.018); 179 | --color-pink-400: oklch(0.718 0.202 349.761); 180 | --color-pink-500: oklch(0.656 0.241 354.308); 181 | --color-pink-600: oklch(0.592 0.249 0.584); 182 | --color-pink-700: oklch(0.525 0.223 3.958); 183 | --color-pink-800: oklch(0.459 0.187 3.815); 184 | --color-pink-900: oklch(0.408 0.153 2.432); 185 | --color-pink-950: oklch(0.284 0.109 3.907); 186 | --color-rose-50: oklch(0.969 0.015 12.422); 187 | --color-rose-100: oklch(0.941 0.03 12.58); 188 | --color-rose-200: oklch(0.892 0.058 10.001); 189 | --color-rose-300: oklch(0.81 0.117 11.638); 190 | --color-rose-400: oklch(0.712 0.194 13.428); 191 | --color-rose-500: oklch(0.645 0.246 16.439); 192 | --color-rose-600: oklch(0.586 0.253 17.585); 193 | --color-rose-700: oklch(0.514 0.222 16.935); 194 | --color-rose-800: oklch(0.455 0.188 13.697); 195 | --color-rose-900: oklch(0.41 0.159 10.272); 196 | --color-rose-950: oklch(0.271 0.105 12.094); 197 | --color-slate-50: oklch(0.984 0.003 247.858); 198 | --color-slate-100: oklch(0.968 0.007 247.896); 199 | --color-slate-200: oklch(0.929 0.013 255.508); 200 | --color-slate-300: oklch(0.869 0.022 252.894); 201 | --color-slate-400: oklch(0.704 0.04 256.788); 202 | --color-slate-500: oklch(0.554 0.046 257.417); 203 | --color-slate-600: oklch(0.446 0.043 257.281); 204 | --color-slate-700: oklch(0.372 0.044 257.287); 205 | --color-slate-800: oklch(0.279 0.041 260.031); 206 | --color-slate-900: oklch(0.208 0.042 265.755); 207 | --color-slate-950: oklch(0.129 0.042 264.695); 208 | --color-gray-50: oklch(0.985 0.002 247.839); 209 | --color-gray-100: oklch(0.967 0.003 264.542); 210 | --color-gray-200: oklch(0.928 0.006 264.531); 211 | --color-gray-300: oklch(0.872 0.01 258.338); 212 | --color-gray-400: oklch(0.707 0.022 261.325); 213 | --color-gray-500: oklch(0.551 0.027 264.364); 214 | --color-gray-600: oklch(0.446 0.03 256.802); 215 | --color-gray-700: oklch(0.373 0.034 259.733); 216 | --color-gray-800: oklch(0.278 0.033 256.848); 217 | --color-gray-900: oklch(0.21 0.034 264.665); 218 | --color-gray-950: oklch(0.13 0.028 261.692); 219 | --color-zinc-50: oklch(0.985 0 0); 220 | --color-zinc-100: oklch(0.967 0.001 286.375); 221 | --color-zinc-200: oklch(0.92 0.004 286.32); 222 | --color-zinc-300: oklch(0.871 0.006 286.286); 223 | --color-zinc-400: oklch(0.705 0.015 286.067); 224 | --color-zinc-500: oklch(0.552 0.016 285.938); 225 | --color-zinc-600: oklch(0.442 0.017 285.786); 226 | --color-zinc-700: oklch(0.37 0.013 285.805); 227 | --color-zinc-800: oklch(0.274 0.006 286.033); 228 | --color-zinc-900: oklch(0.21 0.006 285.885); 229 | --color-zinc-950: oklch(0.141 0.005 285.823); 230 | --color-neutral-50: oklch(0.985 0 0); 231 | --color-neutral-100: oklch(0.97 0 0); 232 | --color-neutral-200: oklch(0.922 0 0); 233 | --color-neutral-300: oklch(0.87 0 0); 234 | --color-neutral-400: oklch(0.708 0 0); 235 | --color-neutral-500: oklch(0.556 0 0); 236 | --color-neutral-600: oklch(0.439 0 0); 237 | --color-neutral-700: oklch(0.371 0 0); 238 | --color-neutral-800: oklch(0.269 0 0); 239 | --color-neutral-900: oklch(0.205 0 0); 240 | --color-neutral-950: oklch(0.145 0 0); 241 | --color-stone-50: oklch(0.985 0.001 106.423); 242 | --color-stone-100: oklch(0.97 0.001 106.424); 243 | --color-stone-200: oklch(0.923 0.003 48.717); 244 | --color-stone-300: oklch(0.869 0.005 56.366); 245 | --color-stone-400: oklch(0.709 0.01 56.259); 246 | --color-stone-500: oklch(0.553 0.013 58.071); 247 | --color-stone-600: oklch(0.444 0.011 73.639); 248 | --color-stone-700: oklch(0.374 0.01 67.558); 249 | --color-stone-800: oklch(0.268 0.007 34.298); 250 | --color-stone-900: oklch(0.216 0.006 56.043); 251 | --color-stone-950: oklch(0.147 0.004 49.25); 252 | --color-black: #000; 253 | --color-white: #fff; 254 | --spacing: 0.25rem; 255 | --breakpoint-sm: 40rem; 256 | --breakpoint-md: 48rem; 257 | --breakpoint-lg: 64rem; 258 | --breakpoint-xl: 80rem; 259 | --breakpoint-2xl: 96rem; 260 | --container-3xs: 16rem; 261 | --container-2xs: 18rem; 262 | --container-xs: 20rem; 263 | --container-sm: 24rem; 264 | --container-md: 28rem; 265 | --container-lg: 32rem; 266 | --container-xl: 36rem; 267 | --container-2xl: 42rem; 268 | --container-3xl: 48rem; 269 | --container-4xl: 56rem; 270 | --container-5xl: 64rem; 271 | --container-6xl: 72rem; 272 | --container-7xl: 80rem; 273 | --text-xs: 0.75rem; 274 | --text-xs--line-height: calc(1 / 0.75); 275 | --text-sm: 0.875rem; 276 | --text-sm--line-height: calc(1.25 / 0.875); 277 | --text-base: 1rem; 278 | --text-base--line-height: calc(1.5 / 1); 279 | --text-lg: 1.125rem; 280 | --text-lg--line-height: calc(1.75 / 1.125); 281 | --text-xl: 1.25rem; 282 | --text-xl--line-height: calc(1.75 / 1.25); 283 | --text-2xl: 1.5rem; 284 | --text-2xl--line-height: calc(2 / 1.5); 285 | --text-3xl: 1.875rem; 286 | --text-3xl--line-height: calc(2.25 / 1.875); 287 | --text-4xl: 2.25rem; 288 | --text-4xl--line-height: calc(2.5 / 2.25); 289 | --text-5xl: 3rem; 290 | --text-5xl--line-height: 1; 291 | --text-6xl: 3.75rem; 292 | --text-6xl--line-height: 1; 293 | --text-7xl: 4.5rem; 294 | --text-7xl--line-height: 1; 295 | --text-8xl: 6rem; 296 | --text-8xl--line-height: 1; 297 | --text-9xl: 8rem; 298 | --text-9xl--line-height: 1; 299 | --font-weight-thin: 100; 300 | --font-weight-extralight: 200; 301 | --font-weight-light: 300; 302 | --font-weight-normal: 400; 303 | --font-weight-medium: 500; 304 | --font-weight-semibold: 600; 305 | --font-weight-bold: 700; 306 | --font-weight-extrabold: 800; 307 | --font-weight-black: 900; 308 | --tracking-tighter: -0.05em; 309 | --tracking-tight: -0.025em; 310 | --tracking-normal: 0em; 311 | --tracking-wide: 0.025em; 312 | --tracking-wider: 0.05em; 313 | --tracking-widest: 0.1em; 314 | --leading-tight: 1.25; 315 | --leading-snug: 1.375; 316 | --leading-normal: 1.5; 317 | --leading-relaxed: 1.625; 318 | --leading-loose: 2; 319 | --radius-xs: 0.125rem; 320 | --radius-sm: 0.25rem; 321 | --radius-md: 0.375rem; 322 | --radius-lg: 0.5rem; 323 | --radius-xl: 0.75rem; 324 | --radius-2xl: 1rem; 325 | --radius-3xl: 1.5rem; 326 | --radius-4xl: 2rem; 327 | --shadow-2xs: 0 1px rgb(0 0 0 / 0.05); 328 | --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); 329 | --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 330 | --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 331 | --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 332 | --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); 333 | --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); 334 | --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05); 335 | --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05); 336 | --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05); 337 | --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05); 338 | --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15); 339 | --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12); 340 | --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15); 341 | --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1); 342 | --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15); 343 | --ease-in: cubic-bezier(0.4, 0, 1, 1); 344 | --ease-out: cubic-bezier(0, 0, 0.2, 1); 345 | --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); 346 | --animate-spin: spin 1s linear infinite; 347 | --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; 348 | --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 349 | --animate-bounce: bounce 1s infinite; 350 | --blur-xs: 4px; 351 | --blur-sm: 8px; 352 | --blur-md: 12px; 353 | --blur-lg: 16px; 354 | --blur-xl: 24px; 355 | --blur-2xl: 40px; 356 | --blur-3xl: 64px; 357 | --perspective-dramatic: 100px; 358 | --perspective-near: 300px; 359 | --perspective-normal: 500px; 360 | --perspective-midrange: 800px; 361 | --perspective-distant: 1200px; 362 | --aspect-video: 16 / 9; 363 | --default-transition-duration: 150ms; 364 | --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 365 | --default-font-family: var(--font-sans); 366 | --default-font-feature-settings: var(--font-sans--font-feature-settings); 367 | --default-font-variation-settings: var(--font-sans--font-variation-settings); 368 | --default-mono-font-family: var(--font-mono); 369 | --default-mono-font-feature-settings: var(--font-mono--font-feature-settings); 370 | --default-mono-font-variation-settings: var(--font-mono--font-variation-settings); 371 | } 372 | } 373 | @layer base { 374 | *, ::after, ::before, ::backdrop, ::file-selector-button { 375 | box-sizing: border-box; 376 | margin: 0; 377 | padding: 0; 378 | border: 0 solid; 379 | } 380 | html, :host { 381 | line-height: 1.5; 382 | -webkit-text-size-adjust: 100%; 383 | tab-size: 4; 384 | font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' ); 385 | font-feature-settings: var(--default-font-feature-settings, normal); 386 | font-variation-settings: var(--default-font-variation-settings, normal); 387 | -webkit-tap-highlight-color: transparent; 388 | } 389 | body { 390 | line-height: inherit; 391 | } 392 | hr { 393 | height: 0; 394 | color: inherit; 395 | border-top-width: 1px; 396 | } 397 | abbr:where([title]) { 398 | -webkit-text-decoration: underline dotted; 399 | text-decoration: underline dotted; 400 | } 401 | h1, h2, h3, h4, h5, h6 { 402 | font-size: inherit; 403 | font-weight: inherit; 404 | } 405 | a { 406 | color: inherit; 407 | -webkit-text-decoration: inherit; 408 | text-decoration: inherit; 409 | } 410 | b, strong { 411 | font-weight: bolder; 412 | } 413 | code, kbd, samp, pre { 414 | font-family: var( --default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace ); 415 | font-feature-settings: var(--default-mono-font-feature-settings, normal); 416 | font-variation-settings: var(--default-mono-font-variation-settings, normal); 417 | font-size: 1em; 418 | } 419 | small { 420 | font-size: 80%; 421 | } 422 | sub, sup { 423 | font-size: 75%; 424 | line-height: 0; 425 | position: relative; 426 | vertical-align: baseline; 427 | } 428 | sub { 429 | bottom: -0.25em; 430 | } 431 | sup { 432 | top: -0.5em; 433 | } 434 | table { 435 | text-indent: 0; 436 | border-color: inherit; 437 | border-collapse: collapse; 438 | } 439 | :-moz-focusring { 440 | outline: auto; 441 | } 442 | progress { 443 | vertical-align: baseline; 444 | } 445 | summary { 446 | display: list-item; 447 | } 448 | ol, ul, menu { 449 | list-style: none; 450 | } 451 | img, svg, video, canvas, audio, iframe, embed, object { 452 | display: block; 453 | vertical-align: middle; 454 | } 455 | img, video { 456 | max-width: 100%; 457 | height: auto; 458 | } 459 | button, input, select, optgroup, textarea, ::file-selector-button { 460 | font: inherit; 461 | font-feature-settings: inherit; 462 | font-variation-settings: inherit; 463 | letter-spacing: inherit; 464 | color: inherit; 465 | border-radius: 0; 466 | background-color: transparent; 467 | opacity: 1; 468 | } 469 | :where(select:is([multiple], [size])) optgroup { 470 | font-weight: bolder; 471 | } 472 | :where(select:is([multiple], [size])) optgroup option { 473 | padding-inline-start: 20px; 474 | } 475 | ::file-selector-button { 476 | margin-inline-end: 4px; 477 | } 478 | ::placeholder { 479 | opacity: 1; 480 | color: color-mix(in oklab, currentColor 50%, transparent); 481 | } 482 | textarea { 483 | resize: vertical; 484 | } 485 | ::-webkit-search-decoration { 486 | -webkit-appearance: none; 487 | } 488 | ::-webkit-date-and-time-value { 489 | min-height: 1lh; 490 | text-align: inherit; 491 | } 492 | ::-webkit-datetime-edit { 493 | display: inline-flex; 494 | } 495 | ::-webkit-datetime-edit-fields-wrapper { 496 | padding: 0; 497 | } 498 | ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { 499 | padding-block: 0; 500 | } 501 | :-moz-ui-invalid { 502 | box-shadow: none; 503 | } 504 | button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { 505 | appearance: button; 506 | } 507 | ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { 508 | height: auto; 509 | } 510 | [hidden]:where(:not([hidden='until-found'])) { 511 | display: none !important; 512 | } 513 | } 514 | @layer utilities { 515 | .sr-only { 516 | position: absolute; 517 | width: 1px; 518 | height: 1px; 519 | padding: 0; 520 | margin: -1px; 521 | overflow: hidden; 522 | clip: rect(0, 0, 0, 0); 523 | white-space: nowrap; 524 | border-width: 0; 525 | } 526 | .absolute { 527 | position: absolute; 528 | } 529 | .static { 530 | position: static; 531 | } 532 | .top-0 { 533 | top: calc(var(--spacing) * 0); 534 | } 535 | .mx-auto { 536 | margin-inline: auto; 537 | } 538 | .mt-1 { 539 | margin-top: calc(var(--spacing) * 1); 540 | } 541 | .mt-2 { 542 | margin-top: calc(var(--spacing) * 2); 543 | } 544 | .mt-4 { 545 | margin-top: calc(var(--spacing) * 4); 546 | } 547 | .mt-5 { 548 | margin-top: calc(var(--spacing) * 5); 549 | } 550 | .mt-6 { 551 | margin-top: calc(var(--spacing) * 6); 552 | } 553 | .mt-8 { 554 | margin-top: calc(var(--spacing) * 8); 555 | } 556 | .mt-10 { 557 | margin-top: calc(var(--spacing) * 10); 558 | } 559 | .mb-2 { 560 | margin-bottom: calc(var(--spacing) * 2); 561 | } 562 | .mb-4 { 563 | margin-bottom: calc(var(--spacing) * 4); 564 | } 565 | .ml-2 { 566 | margin-left: calc(var(--spacing) * 2); 567 | } 568 | .ml-3 { 569 | margin-left: calc(var(--spacing) * 3); 570 | } 571 | .ml-10 { 572 | margin-left: calc(var(--spacing) * 10); 573 | } 574 | .block { 575 | display: block; 576 | } 577 | .contents { 578 | display: contents; 579 | } 580 | .flex { 581 | display: flex; 582 | } 583 | .grid { 584 | display: grid; 585 | } 586 | .hidden { 587 | display: none; 588 | } 589 | .inline-flex { 590 | display: inline-flex; 591 | } 592 | .table { 593 | display: table; 594 | } 595 | .h-16 { 596 | height: calc(var(--spacing) * 16); 597 | } 598 | .h-96 { 599 | height: calc(var(--spacing) * 96); 600 | } 601 | .h-full { 602 | height: 100%; 603 | } 604 | .min-h-full { 605 | min-height: 100%; 606 | } 607 | .w-full { 608 | width: 100%; 609 | } 610 | .max-w-3xl { 611 | max-width: var(--container-3xl); 612 | } 613 | .max-w-7xl { 614 | max-width: var(--container-7xl); 615 | } 616 | .max-w-md { 617 | max-width: var(--container-md); 618 | } 619 | .min-w-full { 620 | min-width: 100%; 621 | } 622 | .flex-shrink-0 { 623 | flex-shrink: 0; 624 | } 625 | .flex-grow { 626 | flex-grow: 1; 627 | } 628 | .cursor-pointer { 629 | cursor: pointer; 630 | } 631 | .grid-cols-1 { 632 | grid-template-columns: repeat(1, minmax(0, 1fr)); 633 | } 634 | .flex-col { 635 | flex-direction: column; 636 | } 637 | .items-baseline { 638 | align-items: baseline; 639 | } 640 | .items-center { 641 | align-items: center; 642 | } 643 | .justify-between { 644 | justify-content: space-between; 645 | } 646 | .justify-center { 647 | justify-content: center; 648 | } 649 | .justify-end { 650 | justify-content: flex-end; 651 | } 652 | .gap-2 { 653 | gap: calc(var(--spacing) * 2); 654 | } 655 | .gap-4 { 656 | gap: calc(var(--spacing) * 4); 657 | } 658 | .gap-6 { 659 | gap: calc(var(--spacing) * 6); 660 | } 661 | .space-y-4 { 662 | :where(& > :not(:last-child)) { 663 | --tw-space-y-reverse: 0; 664 | margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); 665 | margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); 666 | } 667 | } 668 | .space-y-6 { 669 | :where(& > :not(:last-child)) { 670 | --tw-space-y-reverse: 0; 671 | margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); 672 | margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); 673 | } 674 | } 675 | .space-y-8 { 676 | :where(& > :not(:last-child)) { 677 | --tw-space-y-reverse: 0; 678 | margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); 679 | margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); 680 | } 681 | } 682 | .space-x-4 { 683 | :where(& > :not(:last-child)) { 684 | --tw-space-x-reverse: 0; 685 | margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); 686 | margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); 687 | } 688 | } 689 | .divide-y { 690 | :where(& > :not(:last-child)) { 691 | --tw-divide-y-reverse: 0; 692 | border-bottom-style: var(--tw-border-style); 693 | border-top-style: var(--tw-border-style); 694 | border-top-width: calc(1px * var(--tw-divide-y-reverse)); 695 | border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); 696 | } 697 | } 698 | .divide-slate-200 { 699 | :where(& > :not(:last-child)) { 700 | border-color: var(--color-slate-200); 701 | } 702 | } 703 | .truncate { 704 | overflow: hidden; 705 | text-overflow: ellipsis; 706 | white-space: nowrap; 707 | } 708 | .overflow-hidden { 709 | overflow: hidden; 710 | } 711 | .overflow-x-auto { 712 | overflow-x: auto; 713 | } 714 | .rounded { 715 | border-radius: 0.25rem; 716 | } 717 | .rounded-full { 718 | border-radius: calc(infinity * 1px); 719 | } 720 | .rounded-lg { 721 | border-radius: var(--radius-lg); 722 | } 723 | .rounded-md { 724 | border-radius: var(--radius-md); 725 | } 726 | .border { 727 | border-style: var(--tw-border-style); 728 | border-width: 1px; 729 | } 730 | .border-0 { 731 | border-style: var(--tw-border-style); 732 | border-width: 0px; 733 | } 734 | .border-4 { 735 | border-style: var(--tw-border-style); 736 | border-width: 4px; 737 | } 738 | .border-t { 739 | border-top-style: var(--tw-border-style); 740 | border-top-width: 1px; 741 | } 742 | .border-b { 743 | border-bottom-style: var(--tw-border-style); 744 | border-bottom-width: 1px; 745 | } 746 | .border-dashed { 747 | --tw-border-style: dashed; 748 | border-style: dashed; 749 | } 750 | .border-red-200 { 751 | border-color: var(--color-red-200); 752 | } 753 | .border-slate-100 { 754 | border-color: var(--color-slate-100); 755 | } 756 | .border-slate-200 { 757 | border-color: var(--color-slate-200); 758 | } 759 | .border-slate-300 { 760 | border-color: var(--color-slate-300); 761 | } 762 | .border-slate-700 { 763 | border-color: var(--color-slate-700); 764 | } 765 | .border-slate-800 { 766 | border-color: var(--color-slate-800); 767 | } 768 | .border-transparent { 769 | border-color: transparent; 770 | } 771 | .bg-red-50 { 772 | background-color: var(--color-red-50); 773 | } 774 | .bg-red-100 { 775 | background-color: var(--color-red-100); 776 | } 777 | .bg-red-600 { 778 | background-color: var(--color-red-600); 779 | } 780 | .bg-slate-50 { 781 | background-color: var(--color-slate-50); 782 | } 783 | .bg-slate-100 { 784 | background-color: var(--color-slate-100); 785 | } 786 | .bg-slate-800 { 787 | background-color: var(--color-slate-800); 788 | } 789 | .bg-slate-900 { 790 | background-color: var(--color-slate-900); 791 | } 792 | .bg-white { 793 | background-color: var(--color-white); 794 | } 795 | .p-2 { 796 | padding: calc(var(--spacing) * 2); 797 | } 798 | .p-4 { 799 | padding: calc(var(--spacing) * 4); 800 | } 801 | .p-6 { 802 | padding: calc(var(--spacing) * 6); 803 | } 804 | .px-1\.5 { 805 | padding-inline: calc(var(--spacing) * 1.5); 806 | } 807 | .px-2 { 808 | padding-inline: calc(var(--spacing) * 2); 809 | } 810 | .px-3 { 811 | padding-inline: calc(var(--spacing) * 3); 812 | } 813 | .px-4 { 814 | padding-inline: calc(var(--spacing) * 4); 815 | } 816 | .px-6 { 817 | padding-inline: calc(var(--spacing) * 6); 818 | } 819 | .py-0\.5 { 820 | padding-block: calc(var(--spacing) * 0.5); 821 | } 822 | .py-1 { 823 | padding-block: calc(var(--spacing) * 1); 824 | } 825 | .py-1\.5 { 826 | padding-block: calc(var(--spacing) * 1.5); 827 | } 828 | .py-2 { 829 | padding-block: calc(var(--spacing) * 2); 830 | } 831 | .py-3 { 832 | padding-block: calc(var(--spacing) * 3); 833 | } 834 | .py-4 { 835 | padding-block: calc(var(--spacing) * 4); 836 | } 837 | .py-5 { 838 | padding-block: calc(var(--spacing) * 5); 839 | } 840 | .py-6 { 841 | padding-block: calc(var(--spacing) * 6); 842 | } 843 | .py-8 { 844 | padding-block: calc(var(--spacing) * 8); 845 | } 846 | .py-12 { 847 | padding-block: calc(var(--spacing) * 12); 848 | } 849 | .pt-4 { 850 | padding-top: calc(var(--spacing) * 4); 851 | } 852 | .pt-10 { 853 | padding-top: calc(var(--spacing) * 10); 854 | } 855 | .pr-10 { 856 | padding-right: calc(var(--spacing) * 10); 857 | } 858 | .pb-5 { 859 | padding-bottom: calc(var(--spacing) * 5); 860 | } 861 | .pl-3 { 862 | padding-left: calc(var(--spacing) * 3); 863 | } 864 | .text-center { 865 | text-align: center; 866 | } 867 | .text-left { 868 | text-align: left; 869 | } 870 | .text-2xl { 871 | font-size: var(--text-2xl); 872 | line-height: var(--tw-leading, var(--text-2xl--line-height)); 873 | } 874 | .text-3xl { 875 | font-size: var(--text-3xl); 876 | line-height: var(--tw-leading, var(--text-3xl--line-height)); 877 | } 878 | .text-base { 879 | font-size: var(--text-base); 880 | line-height: var(--tw-leading, var(--text-base--line-height)); 881 | } 882 | .text-lg { 883 | font-size: var(--text-lg); 884 | line-height: var(--tw-leading, var(--text-lg--line-height)); 885 | } 886 | .text-sm { 887 | font-size: var(--text-sm); 888 | line-height: var(--tw-leading, var(--text-sm--line-height)); 889 | } 890 | .text-xl { 891 | font-size: var(--text-xl); 892 | line-height: var(--tw-leading, var(--text-xl--line-height)); 893 | } 894 | .text-xs { 895 | font-size: var(--text-xs); 896 | line-height: var(--tw-leading, var(--text-xs--line-height)); 897 | } 898 | .leading-5 { 899 | --tw-leading: calc(var(--spacing) * 5); 900 | line-height: calc(var(--spacing) * 5); 901 | } 902 | .leading-6 { 903 | --tw-leading: calc(var(--spacing) * 6); 904 | line-height: calc(var(--spacing) * 6); 905 | } 906 | .leading-tight { 907 | --tw-leading: var(--leading-tight); 908 | line-height: var(--leading-tight); 909 | } 910 | .font-bold { 911 | --tw-font-weight: var(--font-weight-bold); 912 | font-weight: var(--font-weight-bold); 913 | } 914 | .font-medium { 915 | --tw-font-weight: var(--font-weight-medium); 916 | font-weight: var(--font-weight-medium); 917 | } 918 | .font-normal { 919 | --tw-font-weight: var(--font-weight-normal); 920 | font-weight: var(--font-weight-normal); 921 | } 922 | .font-semibold { 923 | --tw-font-weight: var(--font-weight-semibold); 924 | font-weight: var(--font-weight-semibold); 925 | } 926 | .tracking-tight { 927 | --tw-tracking: var(--tracking-tight); 928 | letter-spacing: var(--tracking-tight); 929 | } 930 | .tracking-wider { 931 | --tw-tracking: var(--tracking-wider); 932 | letter-spacing: var(--tracking-wider); 933 | } 934 | .whitespace-nowrap { 935 | white-space: nowrap; 936 | } 937 | .text-red-600 { 938 | color: var(--color-red-600); 939 | } 940 | .text-red-700 { 941 | color: var(--color-red-700); 942 | } 943 | .text-red-800 { 944 | color: var(--color-red-800); 945 | } 946 | .text-slate-300 { 947 | color: var(--color-slate-300); 948 | } 949 | .text-slate-400 { 950 | color: var(--color-slate-400); 951 | } 952 | .text-slate-500 { 953 | color: var(--color-slate-500); 954 | } 955 | .text-slate-600 { 956 | color: var(--color-slate-600); 957 | } 958 | .text-slate-700 { 959 | color: var(--color-slate-700); 960 | } 961 | .text-slate-800 { 962 | color: var(--color-slate-800); 963 | } 964 | .text-slate-900 { 965 | color: var(--color-slate-900); 966 | } 967 | .text-white { 968 | color: var(--color-white); 969 | } 970 | .uppercase { 971 | text-transform: uppercase; 972 | } 973 | .ring-1 { 974 | --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor); 975 | box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 976 | } 977 | .shadow { 978 | --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); 979 | box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 980 | } 981 | .shadow-lg { 982 | --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); 983 | box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 984 | } 985 | .shadow-sm { 986 | --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); 987 | box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 988 | } 989 | .ring-slate-300 { 990 | --tw-ring-color: var(--color-slate-300); 991 | } 992 | .transition { 993 | transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter; 994 | transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); 995 | transition-duration: var(--tw-duration, var(--default-transition-duration)); 996 | } 997 | .transition-all { 998 | transition-property: all; 999 | transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); 1000 | transition-duration: var(--tw-duration, var(--default-transition-duration)); 1001 | } 1002 | .transition-colors { 1003 | transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; 1004 | transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); 1005 | transition-duration: var(--tw-duration, var(--default-transition-duration)); 1006 | } 1007 | .duration-150 { 1008 | --tw-duration: 150ms; 1009 | transition-duration: 150ms; 1010 | } 1011 | .duration-200 { 1012 | --tw-duration: 200ms; 1013 | transition-duration: 200ms; 1014 | } 1015 | .ease-in-out { 1016 | --tw-ease: var(--ease-in-out); 1017 | transition-timing-function: var(--ease-in-out); 1018 | } 1019 | .ring-inset { 1020 | --tw-ring-inset: inset; 1021 | } 1022 | .file\:mr-4 { 1023 | &::file-selector-button { 1024 | margin-right: calc(var(--spacing) * 4); 1025 | } 1026 | } 1027 | .file\:rounded-full { 1028 | &::file-selector-button { 1029 | border-radius: calc(infinity * 1px); 1030 | } 1031 | } 1032 | .file\:border-0 { 1033 | &::file-selector-button { 1034 | border-style: var(--tw-border-style); 1035 | border-width: 0px; 1036 | } 1037 | } 1038 | .file\:bg-slate-50 { 1039 | &::file-selector-button { 1040 | background-color: var(--color-slate-50); 1041 | } 1042 | } 1043 | .file\:px-4 { 1044 | &::file-selector-button { 1045 | padding-inline: calc(var(--spacing) * 4); 1046 | } 1047 | } 1048 | .file\:py-2 { 1049 | &::file-selector-button { 1050 | padding-block: calc(var(--spacing) * 2); 1051 | } 1052 | } 1053 | .file\:text-sm { 1054 | &::file-selector-button { 1055 | font-size: var(--text-sm); 1056 | line-height: var(--tw-leading, var(--text-sm--line-height)); 1057 | } 1058 | } 1059 | .file\:font-semibold { 1060 | &::file-selector-button { 1061 | --tw-font-weight: var(--font-weight-semibold); 1062 | font-weight: var(--font-weight-semibold); 1063 | } 1064 | } 1065 | .file\:text-slate-700 { 1066 | &::file-selector-button { 1067 | color: var(--color-slate-700); 1068 | } 1069 | } 1070 | .placeholder\:text-slate-400 { 1071 | &::placeholder { 1072 | color: var(--color-slate-400); 1073 | } 1074 | } 1075 | .hover\:bg-red-200 { 1076 | &:hover { 1077 | @media (hover: hover) { 1078 | background-color: var(--color-red-200); 1079 | } 1080 | } 1081 | } 1082 | .hover\:bg-red-700 { 1083 | &:hover { 1084 | @media (hover: hover) { 1085 | background-color: var(--color-red-700); 1086 | } 1087 | } 1088 | } 1089 | .hover\:bg-red-900\/50 { 1090 | &:hover { 1091 | @media (hover: hover) { 1092 | background-color: color-mix(in oklab, var(--color-red-900) 50%, transparent); 1093 | } 1094 | } 1095 | } 1096 | .hover\:bg-slate-50 { 1097 | &:hover { 1098 | @media (hover: hover) { 1099 | background-color: var(--color-slate-50); 1100 | } 1101 | } 1102 | } 1103 | .hover\:bg-slate-200 { 1104 | &:hover { 1105 | @media (hover: hover) { 1106 | background-color: var(--color-slate-200); 1107 | } 1108 | } 1109 | } 1110 | .hover\:bg-slate-700 { 1111 | &:hover { 1112 | @media (hover: hover) { 1113 | background-color: var(--color-slate-700); 1114 | } 1115 | } 1116 | } 1117 | .hover\:bg-slate-800 { 1118 | &:hover { 1119 | @media (hover: hover) { 1120 | background-color: var(--color-slate-800); 1121 | } 1122 | } 1123 | } 1124 | .hover\:text-slate-900 { 1125 | &:hover { 1126 | @media (hover: hover) { 1127 | color: var(--color-slate-900); 1128 | } 1129 | } 1130 | } 1131 | .hover\:text-white { 1132 | &:hover { 1133 | @media (hover: hover) { 1134 | color: var(--color-white); 1135 | } 1136 | } 1137 | } 1138 | .hover\:file\:bg-slate-100 { 1139 | &:hover { 1140 | @media (hover: hover) { 1141 | &::file-selector-button { 1142 | background-color: var(--color-slate-100); 1143 | } 1144 | } 1145 | } 1146 | } 1147 | .focus\:border-red-500 { 1148 | &:focus { 1149 | border-color: var(--color-red-500); 1150 | } 1151 | } 1152 | .focus\:border-slate-500 { 1153 | &:focus { 1154 | border-color: var(--color-slate-500); 1155 | } 1156 | } 1157 | .focus\:ring-2 { 1158 | &:focus { 1159 | --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor); 1160 | box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 1161 | } 1162 | } 1163 | .focus\:ring-red-500 { 1164 | &:focus { 1165 | --tw-ring-color: var(--color-red-500); 1166 | } 1167 | } 1168 | .focus\:ring-red-600 { 1169 | &:focus { 1170 | --tw-ring-color: var(--color-red-600); 1171 | } 1172 | } 1173 | .focus\:ring-slate-500 { 1174 | &:focus { 1175 | --tw-ring-color: var(--color-slate-500); 1176 | } 1177 | } 1178 | .focus\:ring-offset-2 { 1179 | &:focus { 1180 | --tw-ring-offset-width: 2px; 1181 | --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1182 | } 1183 | } 1184 | .focus\:outline-none { 1185 | &:focus { 1186 | --tw-outline-style: none; 1187 | outline-style: none; 1188 | } 1189 | } 1190 | .focus\:ring-inset { 1191 | &:focus { 1192 | --tw-ring-inset: inset; 1193 | } 1194 | } 1195 | .focus-visible\:outline { 1196 | &:focus-visible { 1197 | outline-style: var(--tw-outline-style); 1198 | outline-width: 1px; 1199 | } 1200 | } 1201 | .focus-visible\:outline-2 { 1202 | &:focus-visible { 1203 | outline-style: var(--tw-outline-style); 1204 | outline-width: 2px; 1205 | } 1206 | } 1207 | .focus-visible\:outline-offset-2 { 1208 | &:focus-visible { 1209 | outline-offset: 2px; 1210 | } 1211 | } 1212 | .focus-visible\:outline-slate-600 { 1213 | &:focus-visible { 1214 | outline-color: var(--color-slate-600); 1215 | } 1216 | } 1217 | .disabled\:cursor-not-allowed { 1218 | &:disabled { 1219 | cursor: not-allowed; 1220 | } 1221 | } 1222 | .disabled\:bg-slate-300 { 1223 | &:disabled { 1224 | background-color: var(--color-slate-300); 1225 | } 1226 | } 1227 | .disabled\:opacity-50 { 1228 | &:disabled { 1229 | opacity: 50%; 1230 | } 1231 | } 1232 | .sm\:mx-auto { 1233 | @media (width >= 40rem) { 1234 | margin-inline: auto; 1235 | } 1236 | } 1237 | .sm\:mt-0 { 1238 | @media (width >= 40rem) { 1239 | margin-top: calc(var(--spacing) * 0); 1240 | } 1241 | } 1242 | .sm\:flex { 1243 | @media (width >= 40rem) { 1244 | display: flex; 1245 | } 1246 | } 1247 | .sm\:w-full { 1248 | @media (width >= 40rem) { 1249 | width: 100%; 1250 | } 1251 | } 1252 | .sm\:max-w-md { 1253 | @media (width >= 40rem) { 1254 | max-width: var(--container-md); 1255 | } 1256 | } 1257 | .sm\:max-w-xs { 1258 | @media (width >= 40rem) { 1259 | max-width: var(--container-xs); 1260 | } 1261 | } 1262 | .sm\:items-end { 1263 | @media (width >= 40rem) { 1264 | align-items: flex-end; 1265 | } 1266 | } 1267 | .sm\:gap-4 { 1268 | @media (width >= 40rem) { 1269 | gap: calc(var(--spacing) * 4); 1270 | } 1271 | } 1272 | .sm\:rounded-lg { 1273 | @media (width >= 40rem) { 1274 | border-radius: var(--radius-lg); 1275 | } 1276 | } 1277 | .sm\:rounded-md { 1278 | @media (width >= 40rem) { 1279 | border-radius: var(--radius-md); 1280 | } 1281 | } 1282 | .sm\:p-6 { 1283 | @media (width >= 40rem) { 1284 | padding: calc(var(--spacing) * 6); 1285 | } 1286 | } 1287 | .sm\:px-0 { 1288 | @media (width >= 40rem) { 1289 | padding-inline: calc(var(--spacing) * 0); 1290 | } 1291 | } 1292 | .sm\:px-6 { 1293 | @media (width >= 40rem) { 1294 | padding-inline: calc(var(--spacing) * 6); 1295 | } 1296 | } 1297 | .sm\:px-10 { 1298 | @media (width >= 40rem) { 1299 | padding-inline: calc(var(--spacing) * 10); 1300 | } 1301 | } 1302 | .sm\:text-sm { 1303 | @media (width >= 40rem) { 1304 | font-size: var(--text-sm); 1305 | line-height: var(--tw-leading, var(--text-sm--line-height)); 1306 | } 1307 | } 1308 | .sm\:leading-6 { 1309 | @media (width >= 40rem) { 1310 | --tw-leading: calc(var(--spacing) * 6); 1311 | line-height: calc(var(--spacing) * 6); 1312 | } 1313 | } 1314 | .md\:block { 1315 | @media (width >= 48rem) { 1316 | display: block; 1317 | } 1318 | } 1319 | .md\:hidden { 1320 | @media (width >= 48rem) { 1321 | display: none; 1322 | } 1323 | } 1324 | .md\:grid-cols-2 { 1325 | @media (width >= 48rem) { 1326 | grid-template-columns: repeat(2, minmax(0, 1fr)); 1327 | } 1328 | } 1329 | .lg\:grid-cols-2 { 1330 | @media (width >= 64rem) { 1331 | grid-template-columns: repeat(2, minmax(0, 1fr)); 1332 | } 1333 | } 1334 | .lg\:grid-cols-3 { 1335 | @media (width >= 64rem) { 1336 | grid-template-columns: repeat(3, minmax(0, 1fr)); 1337 | } 1338 | } 1339 | .lg\:px-8 { 1340 | @media (width >= 64rem) { 1341 | padding-inline: calc(var(--spacing) * 8); 1342 | } 1343 | } 1344 | } 1345 | @keyframes spin { 1346 | to { 1347 | transform: rotate(360deg); 1348 | } 1349 | } 1350 | @keyframes ping { 1351 | 75%, 100% { 1352 | transform: scale(2); 1353 | opacity: 0; 1354 | } 1355 | } 1356 | @keyframes pulse { 1357 | 50% { 1358 | opacity: 0.5; 1359 | } 1360 | } 1361 | @keyframes bounce { 1362 | 0%, 100% { 1363 | transform: translateY(-25%); 1364 | animation-timing-function: cubic-bezier(0.8, 0, 1, 1); 1365 | } 1366 | 50% { 1367 | transform: none; 1368 | animation-timing-function: cubic-bezier(0, 0, 0.2, 1); 1369 | } 1370 | } 1371 | @property --tw-space-y-reverse { 1372 | syntax: "*"; 1373 | inherits: false; 1374 | initial-value: 0; 1375 | } 1376 | @property --tw-space-x-reverse { 1377 | syntax: "*"; 1378 | inherits: false; 1379 | initial-value: 0; 1380 | } 1381 | @property --tw-divide-y-reverse { 1382 | syntax: "*"; 1383 | inherits: false; 1384 | initial-value: 0; 1385 | } 1386 | @property --tw-border-style { 1387 | syntax: "*"; 1388 | inherits: false; 1389 | initial-value: solid; 1390 | } 1391 | @property --tw-leading { 1392 | syntax: "*"; 1393 | inherits: false; 1394 | } 1395 | @property --tw-font-weight { 1396 | syntax: "*"; 1397 | inherits: false; 1398 | } 1399 | @property --tw-tracking { 1400 | syntax: "*"; 1401 | inherits: false; 1402 | } 1403 | @property --tw-shadow { 1404 | syntax: "*"; 1405 | inherits: false; 1406 | initial-value: 0 0 #0000; 1407 | } 1408 | @property --tw-shadow-color { 1409 | syntax: "*"; 1410 | inherits: false; 1411 | } 1412 | @property --tw-inset-shadow { 1413 | syntax: "*"; 1414 | inherits: false; 1415 | initial-value: 0 0 #0000; 1416 | } 1417 | @property --tw-inset-shadow-color { 1418 | syntax: "*"; 1419 | inherits: false; 1420 | } 1421 | @property --tw-ring-color { 1422 | syntax: "*"; 1423 | inherits: false; 1424 | } 1425 | @property --tw-ring-shadow { 1426 | syntax: "*"; 1427 | inherits: false; 1428 | initial-value: 0 0 #0000; 1429 | } 1430 | @property --tw-inset-ring-color { 1431 | syntax: "*"; 1432 | inherits: false; 1433 | } 1434 | @property --tw-inset-ring-shadow { 1435 | syntax: "*"; 1436 | inherits: false; 1437 | initial-value: 0 0 #0000; 1438 | } 1439 | @property --tw-ring-inset { 1440 | syntax: "*"; 1441 | inherits: false; 1442 | } 1443 | @property --tw-ring-offset-width { 1444 | syntax: ""; 1445 | inherits: false; 1446 | initial-value: 0px; 1447 | } 1448 | @property --tw-ring-offset-color { 1449 | syntax: "*"; 1450 | inherits: false; 1451 | initial-value: #fff; 1452 | } 1453 | @property --tw-ring-offset-shadow { 1454 | syntax: "*"; 1455 | inherits: false; 1456 | initial-value: 0 0 #0000; 1457 | } 1458 | @property --tw-duration { 1459 | syntax: "*"; 1460 | inherits: false; 1461 | } 1462 | @property --tw-ease { 1463 | syntax: "*"; 1464 | inherits: false; 1465 | } 1466 | @property --tw-outline-style { 1467 | syntax: "*"; 1468 | inherits: false; 1469 | initial-value: solid; 1470 | } 1471 | --------------------------------------------------------------------------------