diff --git a/app/routers/ordered_products.py b/app/routers/ordered_products.py index e33e00c..f0e6813 100644 --- a/app/routers/ordered_products.py +++ b/app/routers/ordered_products.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, Request from ..store import SortOrderedProductsBy as SortBy -from ..store import select_ordered_products +from ..store import load_ordered_products + # from .. import templates router = APIRouter() @@ -9,7 +10,7 @@ @router.get("/ordered-products") async def get_ordered_products(_: Request, sort_by: SortBy = SortBy.PRODUCT_ID): - ordered_products = await select_ordered_products(sort_by, False, False) + ordered_products = await load_ordered_products(sort_by) # NOTE: Actually, we might not need this template and instead modify and use templates.placements. # templates.ordered_products(request, ordered_products) return ordered_products diff --git a/app/routers/placements.py b/app/routers/placements.py index 9f42b5d..ade9987 100644 --- a/app/routers/placements.py +++ b/app/routers/placements.py @@ -1,42 +1,48 @@ -from typing import Annotated +# from typing import Annotated -from fastapi import APIRouter, Header, Request +# from fastapi import APIRouter, Header, Request +from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse from .. import templates -from ..store import PlacementTable, select_placements +from ..store import ( + PlacementTable, + load_canceled_placements, + load_completed_placements, + load_incoming_placements, +) router = APIRouter() -@router.get("/placements", response_class=HTMLResponse) -async def get_placements( - request: Request, - canceled: bool = False, - completed: bool = False, - hx_request: Annotated[str | None, Header()] = None, -): - placements = await select_placements(canceled, completed) - macro = ( - templates.components.placements - if hx_request == "true" - else templates.placements - ) - return HTMLResponse(macro(request, placements, canceled, completed)) +@router.get("/incoming-placements", response_class=HTMLResponse) +async def get_incoming_placements(request: Request): + placements = await load_incoming_placements() + return HTMLResponse(templates.incoming_placements(request, placements)) -# @router.get("/placements/{placement_id}") -# async def get_placement(request: Request, placement_id: int): -# if (placement := await PlacementTable.by_placement_id(placement_id)) is None: -# raise HTTPException(404, f"Placement {placement_id} not found") -# return templates.components.placement(request, placement) +@router.get("/canceled-placements", response_class=HTMLResponse) +async def get_canceled_placements(request: Request): + placements = await load_canceled_placements() + return HTMLResponse(templates.canceled_placements(request, placements)) -@router.post("/placements/{placement_id}") +@router.get("/completed-placements", response_class=HTMLResponse) +async def get_completed_placements(request: Request): + placements = await load_completed_placements() + return HTMLResponse(templates.completed_placements(request, placements)) + + +@router.post("/incoming-placements/{placement_id}") +async def reset_placement(placement_id: int): + await PlacementTable.reset(placement_id) + + +@router.post("/completed-placements/{placement_id}") async def complete_placement(placement_id: int): await PlacementTable.complete(placement_id) -@router.delete("/placements/{placement_id}") +@router.post("/canceled-placements/{placement_id}") async def cancel_placement(placement_id: int): await PlacementTable.cancel(placement_id) diff --git a/app/store/__init__.py b/app/store/__init__.py index af8a198..f355725 100644 --- a/app/store/__init__.py +++ b/app/store/__init__.py @@ -1,12 +1,15 @@ from datetime import datetime -from enum import Enum -from typing import assert_never +from enum import Enum, auto +from typing import Any, Awaitable, Callable, Mapping, assert_never import sqlalchemy +import sqlmodel from databases import Database -from sqlmodel import SQLModel +from sqlalchemy import orm as sa_orm +from sqlmodel import col from . import placed_item, placement, product +from ._helper import _colname from .placed_item import PlacedItem from .placement import Placement from .product import Product @@ -24,70 +27,235 @@ def _to_time(unix_epoch: int) -> str: return datetime.fromtimestamp(unix_epoch).strftime("%H:%M:%S") -async def select_placements( - canceled: bool, - completed: bool, -) -> list[dict[str, int | list[dict[str, int | str]] | str | datetime | None]]: - # list of products with each row alongside the number of ordered items - query = f""" - SELECT - {Placement.placement_id}, - unixepoch({Placement.placed_at}) as placed_at, - unixepoch({Placement.completed_at}) as completed_at, - {PlacedItem.product_id}, - COUNT({PlacedItem.product_id}) AS count, - {Product.name}, - {Product.filename}, - {Product.price} - FROM {Placement.__tablename__} as {Placement.__name__} - JOIN {PlacedItem.__tablename__} as {PlacedItem.__name__} ON {Placement.placement_id} = {PlacedItem.placement_id} - JOIN {Product.__tablename__} as {Product.__name__} ON {PlacedItem.product_id} = {Product.product_id} - WHERE {Placement.canceled} = {int(canceled)} AND - {Placement.completed} = {int(completed)} - GROUP BY {Placement.placement_id}, {PlacedItem.product_id} - ORDER BY {Placement.placement_id} ASC, {PlacedItem.product_id} ASC - """ - placements: list[ - dict[str, int | list[dict[str, int | str]] | str | datetime | None] - ] = [] - prev_placement_id = -1 - products = [] - total_price = 0 - async for map in database.iterate(query): - if (placement_id := map["placement_id"]) != prev_placement_id: - prev_placement_id = placement_id - if len(placements) > 0: - placements[-1]["total_price"] = Product.to_price_str(total_price) - products = [] - completed_at = _to_time(field) if (field := map["completed_at"]) else None - placements.append( - { - "placement_id": placement_id, - "products": products, - "placed_at": _to_time(map["placed_at"]), - "completed_at": completed_at, - } +type products_t = list[dict[str, int | str]] +type placements_t = list[dict[str, int | products_t | str | datetime | None]] + + +# TODO: there should be a way to use the unixepoch function without this boiler plate +def unixepoch(attr: sa_orm.Mapped) -> sqlalchemy.Label: + colname = _colname(col(attr)) + alias = getattr(attr, "name") + return sqlalchemy.literal_column(f"unixepoch({colname})").label(alias) + + +class SortOrderedProductsBy(Enum): + PRODUCT_ID = "product_id" + TIME = "time" + NO_ITEMS = "no_items" + + def order_by(self) -> sqlalchemy.ColumnElement: + match self: + case self.PRODUCT_ID: + return sqlmodel.asc(col(PlacedItem.product_id)) + case self.TIME: + return sqlmodel.desc(col(Placement.placed_at)) + case self.NO_ITEMS: + return sqlmodel.desc(sqlmodel.literal_column("count")) + + +class PlacementsQuery(Enum): + incoming = auto() + canceled = auto() + completed = auto() + + def by_placement_id(self) -> sqlalchemy.Select: + # Query from the placements table + query: sqlalchemy.Select[Any] = ( + sqlmodel.select(Placement.placement_id) + .group_by(col(Placement.placement_id)) + .order_by(col(Placement.placement_id).asc()) + .add_columns(unixepoch(col(Placement.placed_at))) + ) + + query = self._extra_timestamps(query) + + query = ( + # Query the list of placed items + query.select_from(sqlmodel.join(Placement, PlacedItem)) + .add_columns(col(PlacedItem.product_id)) + .group_by(col(PlacedItem.product_id)) + .order_by(col(PlacedItem.product_id).asc()) + .add_columns(sqlmodel.func.count(col(PlacedItem.product_id)).label("count")) + # Query product information + .join(Product) + .add_columns(col(Product.name), col(Product.filename)) + ) + + # Include prices for canceled/completed placements + if self != self.incoming: + query = query.add_columns(col(Product.price)) + + return query + + def by_ordered_products(self, sort_by: SortOrderedProductsBy) -> sqlalchemy.Select: + query = ( + sqlmodel.select( + PlacedItem.placement_id, + sqlmodel.func.count(col(PlacedItem.product_id)).label("count"), + PlacedItem.product_id, ) - total_price = 0 - count, price = map["count"], map["price"] - products.append( - { - "product_id": map["product_id"], - "count": count, - "name": map["name"], - "filename": map["filename"], - "price": Product.to_price_str(price), - } + .group_by(col(PlacedItem.placement_id), col(PlacedItem.product_id)) + .select_from(sqlmodel.join(PlacedItem, Product)) + .add_columns(col(Product.name), col(Product.filename)) + .join(Placement) ) - total_price += count * price - if len(placements) > 0: - placements[-1]["total_price"] = Product.to_price_str(total_price) - return placements + query = self._extra_timestamps(query) + + return query.order_by(sort_by.order_by()) + + def _extra_timestamps(self, query: sqlalchemy.Select) -> sqlalchemy.Select: + """Conditionally include/exclude extra timestamps.""" + match self: + case self.incoming: + return query.where( + col(Placement.canceled_at).is_(None) + & col(Placement.completed_at).is_(None) + ) + case self.canceled: + return query.where(col(Placement.canceled_at).isnot(None)).add_columns( + unixepoch(col(Placement.canceled_at)) + ) + case self.completed: + return query.where(col(Placement.completed_at).isnot(None)).add_columns( + unixepoch(col(Placement.completed_at)) + ) + + +class _PlacementsLoader: + def __new__(cls, status: PlacementsQuery) -> Callable[[], Awaitable[placements_t]]: + placements: placements_t = [] + + match status: + case status.incoming: + + def init_cb(placement_id: int, map: Mapping) -> products_t: + products = [] + placements.append( + { + "placement_id": placement_id, + "products": products, + "placed_at": _to_time(map["placed_at"]), + } + ) + return products + + def update_product_cb(map: Mapping) -> dict[str, int | str]: + return { + "product_id": map["product_id"], + "count": map["count"], + "name": map["name"], + "filename": map["filename"], + } + + def last_cb(): ... + + case status.canceled: + total_price = 0 + + def init_cb(placement_id: int, map: Mapping) -> products_t: + products = [] + placements.append( + { + "placement_id": placement_id, + "products": products, + "placed_at": _to_time(map["placed_at"]), + "canceled_at": _to_time(map["canceled_at"]), + } + ) + nonlocal total_price + total_price = 0 + return products + + def update_product_cb(map: Mapping) -> dict[str, int | str]: + count, price = map["count"], map["price"] + nonlocal total_price + total_price += count * price + return { + "product_id": map["product_id"], + "count": count, + "name": map["name"], + "filename": map["filename"], + "price": Product.to_price_str(price), + } + + def last_cb() -> None: + if len(placements) > 0: + placements[-1]["total_price"] = Product.to_price_str( + total_price + ) + + case status.completed: + total_price = 0 + + def init_cb(placement_id: int, map: Mapping) -> products_t: + products = [] + placements.append( + { + "placement_id": placement_id, + "products": products, + "placed_at": _to_time(map["placed_at"]), + "completed_at": _to_time(map["completed_at"]), + } + ) + nonlocal total_price + total_price = 0 + return products + + def update_product_cb(map: Mapping) -> dict[str, int | str]: + count, price = map["count"], map["price"] + nonlocal total_price + total_price += count * price + return { + "product_id": map["product_id"], + "count": count, + "name": map["name"], + "filename": map["filename"], + "price": Product.to_price_str(price), + } + + def last_cb() -> None: + if len(placements) > 0: + placements[-1]["total_price"] = Product.to_price_str( + total_price + ) + + case _: + assert_never() + + query = str(status.by_placement_id().compile()) + + async def loader(): + placements.clear() + await cls._execute(query, init_cb, update_product_cb, last_cb) + return placements + + return loader + + @staticmethod + async def _execute( + query: str, + init_cb: Callable[[int, Mapping], products_t], + update_product_cb: Callable[[Mapping], dict[str, int | str]], + last_cb: Callable[[], None], + ): + products = [] + prev_placement_id = -1 + async for map in database.iterate(query): + if (placement_id := map["placement_id"]) != prev_placement_id: + prev_placement_id = placement_id + last_cb() + products = init_cb(placement_id, map) + products.append(update_product_cb(map)) + last_cb() + + +load_incoming_placements = _PlacementsLoader(PlacementsQuery.incoming) +load_canceled_placements = _PlacementsLoader(PlacementsQuery.canceled) +load_completed_placements = _PlacementsLoader(PlacementsQuery.completed) # NOTE:get placements by incoming order in datetime -# TODO: add a datetime field to PlacedItem # # async def select_placements_by_incoming_order() -> dict[int, list[dict]]: # query = f""" @@ -106,41 +274,10 @@ async def select_placements( # return placements -class SortOrderedProductsBy(Enum): - PRODUCT_ID = "product_id" - TIME = "time" - NO_ITEMS = "no_items" - - -async def select_ordered_products( +async def load_ordered_products( sort_by: SortOrderedProductsBy, - canceled: bool, - completed: bool, ) -> list[dict[str, int | str | list[dict[str, int]]]]: - match sort_by: - case SortOrderedProductsBy.PRODUCT_ID: - order_by = f"{PlacedItem.product_id} ASC" - case SortOrderedProductsBy.TIME: - order_by = f"{Placement.placed_at} DESC" - case SortOrderedProductsBy.NO_ITEMS: - order_by = "count DESC" - case _: - assert_never(SortOrderedProductsBy) - query = f""" - SELECT - {PlacedItem.placement_id}, - COUNT({PlacedItem.product_id}) AS count, - {PlacedItem.product_id}, - {Product.name}, - {Product.filename} - FROM {PlacedItem.__tablename__} as {PlacedItem.__name__} - JOIN {Product.__tablename__} as {Product.__name__} ON {PlacedItem.product_id} = {Product.product_id} - JOIN {Placement.__tablename__} as {Placement.__name__} ON {PlacedItem.placement_id} = {Placement.placement_id} - WHERE {Placement.canceled} = {int(canceled)} AND - {Placement.completed} = {int(completed)} - GROUP BY {PlacedItem.placement_id}, {PlacedItem.product_id} - ORDER BY {order_by} - """ + query = PlacementsQuery.incoming.by_ordered_products(sort_by) ret: dict[int, dict[str, int | str | list[dict[str, int]]]] = {} async for map in database.iterate(query): product_id = map["product_id"] @@ -167,7 +304,7 @@ async def _startup_db() -> None: # TODO: we should use a database schema migration tool like Alembic as explained in: # https://www.encode.io/databases/database_queries/#creating-tables - for table in SQLModel.metadata.tables.values(): + for table in sqlmodel.SQLModel.metadata.tables.values(): schema = sqlalchemy.schema.CreateTable(table, if_not_exists=True) query = str(schema.compile()) await database.execute(query) diff --git a/app/store/placement.py b/app/store/placement.py index cfde40e..44d9107 100644 --- a/app/store/placement.py +++ b/app/store/placement.py @@ -1,8 +1,10 @@ from datetime import datetime, timezone from typing import Annotated +import sqlalchemy import sqlmodel from databases import Database +from sqlmodel import col class Placement(sqlmodel.SQLModel, table=True): @@ -11,20 +13,15 @@ class Placement(sqlmodel.SQLModel, table=True): id: int | None = sqlmodel.Field(default=None, primary_key=True) placement_id: int - # Column(..., server_default=sqlalchemy.text("0")) - canceled: Annotated[ - bool, sqlmodel.Field(sa_column_kwargs={"server_default": sqlmodel.text("0")}) - ] - # Column(..., server_default=sqlalchemy.text("0")) - completed: Annotated[ - bool, sqlmodel.Field(sa_column_kwargs={"server_default": sqlmodel.text("0")}) - ] placed_at: Annotated[ datetime, sqlmodel.Field( sa_column_kwargs={"server_default": sqlmodel.text("CURRENT_TIMESTAMP")} ), ] + canceled_at: datetime | None = sqlmodel.Field( + default=None, sa_column=sqlmodel.Column(sqlmodel.DateTime(timezone=True)) + ) completed_at: datetime | None = sqlmodel.Field( default=None, sa_column=sqlmodel.Column(sqlmodel.DateTime(timezone=True)) ) @@ -38,25 +35,22 @@ async def insert(self, placement_id: int) -> None: query = sqlmodel.insert(Placement) await self._db.execute(query, {"placement_id": placement_id}) - async def update(self, placement_id: int, canceled: bool, completed: bool) -> None: - clause = Placement.placement_id == placement_id - # NOTE: I don't why, but this where clause argument does not typecheck - query = sqlmodel.update(Placement).where(clause) # pyright: ignore[reportArgumentType] - values = { - "canceled": canceled, - "completed": completed, - "completed_at": datetime.now(timezone.utc) if completed else None, - } - await self._db.execute(query, values) + @staticmethod + def _update(placement_id: int) -> sqlalchemy.Update: + clause = col(Placement.placement_id) == placement_id + return sqlmodel.update(Placement).where(clause) async def cancel(self, placement_id: int) -> None: - await self.update(placement_id, canceled=True, completed=False) + values = {"canceled_at": datetime.now(timezone.utc), "completed_at": None} + await self._db.execute(self._update(placement_id), values) async def complete(self, placement_id: int) -> None: - await self.update(placement_id, canceled=False, completed=True) + values = {"canceled_at": None, "completed_at": datetime.now(timezone.utc)} + await self._db.execute(self._update(placement_id), values) async def reset(self, placement_id: int) -> None: - await self.update(placement_id, canceled=False, completed=False) + values = {"canceled_at": None, "completed_at": None} + await self._db.execute(self._update(placement_id), values) async def by_placement_id(self, placement_id: int) -> Placement | None: query = sqlmodel.select(Placement).where(Placement.placement_id == placement_id) diff --git a/app/store/test__init__.py b/app/store/test__init__.py new file mode 100644 index 0000000..3405e5f --- /dev/null +++ b/app/store/test__init__.py @@ -0,0 +1,39 @@ +import sqlalchemy +from inline_snapshot import snapshot + +from . import PlacementsQuery + + +def strip_lines(query: sqlalchemy.Select) -> str: + stripped_lines = [line.strip() for line in str(query).split("\n")] + return "\n".join(stripped_lines) + + +def test_incoming_query(): + assert strip_lines(PlacementsQuery.incoming.by_placement_id()) == snapshot( + """\ +SELECT placements.placement_id, unixepoch(placements.placed_at) AS placed_at, placed_items.product_id, count(placed_items.product_id) AS count, products.name, products.filename +FROM placements JOIN placed_items ON placements.placement_id = placed_items.placement_id JOIN products ON products.product_id = placed_items.product_id +WHERE placements.canceled_at IS NULL AND placements.completed_at IS NULL GROUP BY placements.placement_id, placed_items.product_id ORDER BY placements.placement_id ASC, placed_items.product_id ASC\ +""" + ) + + +def test_canceled_query(): + assert strip_lines(PlacementsQuery.canceled.by_placement_id()) == snapshot( + """\ +SELECT placements.placement_id, unixepoch(placements.placed_at) AS placed_at, unixepoch(placements.canceled_at) AS canceled_at, placed_items.product_id, count(placed_items.product_id) AS count, products.name, products.filename, products.price +FROM placements JOIN placed_items ON placements.placement_id = placed_items.placement_id JOIN products ON products.product_id = placed_items.product_id +WHERE placements.canceled_at IS NOT NULL GROUP BY placements.placement_id, placed_items.product_id ORDER BY placements.placement_id ASC, placed_items.product_id ASC\ +""" + ) + + +def test_completed_query(): + assert strip_lines(PlacementsQuery.completed.by_placement_id()) == snapshot( + """\ +SELECT placements.placement_id, unixepoch(placements.placed_at) AS placed_at, unixepoch(placements.completed_at) AS completed_at, placed_items.product_id, count(placed_items.product_id) AS count, products.name, products.filename, products.price +FROM placements JOIN placed_items ON placements.placement_id = placed_items.placement_id JOIN products ON products.product_id = placed_items.product_id +WHERE placements.completed_at IS NOT NULL GROUP BY placements.placement_id, placed_items.product_id ORDER BY placements.placement_id ASC, placed_items.product_id ASC\ +""" + ) diff --git a/app/templates.py b/app/templates.py index 4141291..1a19edf 100644 --- a/app/templates.py +++ b/app/templates.py @@ -1,5 +1,4 @@ import os -from datetime import datetime from functools import wraps from pathlib import Path from typing import Any, Callable, Protocol @@ -10,7 +9,7 @@ from jinja2.ext import debug as debug_ext from .env import DEBUG -from .store import Product +from .store import Product, placements_t TEMPLATES_DIR = Path("app/templates") @@ -69,6 +68,12 @@ def with_request(request, *args: P.args, **kwargs: P.kwargs) -> str: return type_signature +@macro_template("layout.html") +def layout( + title: str = "murchace", head: str = "", caller: Callable[[], str] = lambda: "" +): ... + + @macro_template("index.html") def index(): ... @@ -88,14 +93,16 @@ def orders( ): ... -@macro_template("placements.html") -def placements( - placements: list[ - dict[str, int | list[dict[str, int | str]] | str | datetime | None] - ], - canceled: bool = False, - completed: bool = False, -): ... +@macro_template("incoming-placements.html") +def incoming_placements(placements: placements_t): ... + + +@macro_template("canceled-placements.html") +def canceled_placements(placements: placements_t): ... + + +@macro_template("completed-placements.html") +def completed_placements(placements: placements_t): ... # namespace @@ -114,12 +121,6 @@ def order_session( order_frozen: bool = False, ): ... - @macro_template("components/placements.html") - @staticmethod - def placements( - placements: list[ - dict[str, int | list[dict[str, int | str]] | str | datetime | None] - ], - canceled: bool = False, - completed: bool = False, - ): ... + # @macro_template("components/incoming-placements.html") + # @staticmethod + # def incoming_placements(placements: placements_t): ... diff --git a/app/templates/canceled-placements.html b/app/templates/canceled-placements.html new file mode 100644 index 0000000..5be6371 --- /dev/null +++ b/app/templates/canceled-placements.html @@ -0,0 +1,64 @@ +{% from "layout.html" import layout %} +{% from "components/clock.html" import clock %} + +{% macro canceled_placements(placements) %} + {% call layout("取消した確定注文 - murchace") %} +
+
+ + + {{ clock() }} +
+
+ {% for placement in placements | reverse %} +
+
+
+

#{{ placement.placement_id }}

+ @{{ placement.placed_at }} +
+ +
+
    + {% for product in placement.products %} +
  • + {{ product.name }} + {{ product.price }} x {{ product.count }} +
  • + {% endfor %} +
+

+ 合計金額 + {{ placement.total_price }} +

+ +
+ {% endfor %} +
+
+ {% endcall %} +{% endmacro %} diff --git a/app/templates/completed-placements.html b/app/templates/completed-placements.html new file mode 100644 index 0000000..bc02864 --- /dev/null +++ b/app/templates/completed-placements.html @@ -0,0 +1,64 @@ +{% from "layout.html" import layout %} +{% from "components/clock.html" import clock %} + +{% macro completed_placements(placements) %} + {% call layout("完了した確定注文 - murchace") %} +
+
+ + + {{ clock() }} +
+
+ {% for placement in placements | reverse %} +
+
+
+

#{{ placement.placement_id }}

+ @{{ placement.placed_at }} +
+ +
+
    + {% for product in placement.products %} +
  • + {{ product.name }} + {{ product.price }} x {{ product.count }} +
  • + {% endfor %} +
+

+ 合計金額 + {{ placement.total_price }} +

+ +
+ {% endfor %} +
+
+ {% endcall %} +{% endmacro %} diff --git a/app/templates/components/placements.html b/app/templates/components/placements.html deleted file mode 100644 index 36611e5..0000000 --- a/app/templates/components/placements.html +++ /dev/null @@ -1,50 +0,0 @@ -{% macro placements(placements, canceled = false, completed = false) %} - {% for placement in placements | reverse %} -
-
-
-

#{{ placement.placement_id }}

-

@{{ placement.placed_at }}

- {% if placement.completed_at %} -

/{{ placement.completed_at }}

- {% endif %} -
- {% if canceled == false %} - - {% endif %} -
- -

- 合計金額 - {{ placement.total_price }} -

- {% if completed == false %} - - {% endif %} -
- {% endfor %} -{% endmacro %} diff --git a/app/templates/incoming-placements.html b/app/templates/incoming-placements.html new file mode 100644 index 0000000..8258f7e --- /dev/null +++ b/app/templates/incoming-placements.html @@ -0,0 +1,65 @@ +{% from "layout.html" import layout %} +{% from "components/clock.html" import clock %} + +{% macro _head() %} + +{% endmacro %} + +{% macro incoming_placements(placements) %} + {% call layout("確定注文 - murchace", _head()) %} +
+
+ + + {{ clock() }} +
+
+ {% for placement in placements | reverse %} +
+
+
+

#{{ placement.placement_id }}

+ @{{ placement.placed_at }} +
+ +
+
    + {% for product in placement.products %} +
  • + {{ product.name }} + x {{ product.count }} +
  • + {% endfor %} +
+ +
+ {% endfor %} +
+
+ {% endcall %} +{% endmacro %} diff --git a/app/templates/index.html b/app/templates/index.html index 3cb6205..7115574 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -6,7 +6,7 @@

新しい注文

- +

確定注文一覧

diff --git a/app/templates/placements.html b/app/templates/placements.html deleted file mode 100644 index 6403d92..0000000 --- a/app/templates/placements.html +++ /dev/null @@ -1,49 +0,0 @@ -{% from "layout.html" import layout %} -{% from "components/clock.html" import clock %} -{% from "components/placements.html" import placements as placements_component %} - -{% macro _head() %} - -{% endmacro %} - -{% macro placements(placements, canceled = false, completed = false) %} - {% call layout("確定注文 - murchace", _head()) %} -
-
- -
- - {# TODO: update the path when the radio button is changed through hx-swap-oob #} - -
- {{ clock() }} -
-
- {{ placements_component(placements, canceled, completed) }} -
-
- {% endcall %} -{% endmacro %} diff --git a/static/styles.css b/static/styles.css index c059b29..cab5211 100644 --- a/static/styles.css +++ b/static/styles.css @@ -571,6 +571,11 @@ video { margin-right: 0.25rem; } +.mx-8 { + margin-left: 2rem; + margin-right: 2rem; +} + .mx-auto { margin-left: auto; margin-right: auto; @@ -733,28 +738,15 @@ video { gap: 0.75rem; } -.gap-6 { - gap: 1.5rem; -} - .gap-x-2 { -moz-column-gap: 0.5rem; column-gap: 0.5rem; } -.gap-x-6 { - -moz-column-gap: 1.5rem; - column-gap: 1.5rem; -} - .gap-y-2 { row-gap: 0.5rem; } -.gap-y-3 { - row-gap: 0.75rem; -} - .divide-y-4 > :not([hidden]) ~ :not([hidden]) { --tw-divide-y-reverse: 0; border-top-width: calc(4px * calc(1 - var(--tw-divide-y-reverse))); @@ -829,11 +821,6 @@ video { border-color: rgb(96 165 250 / var(--tw-border-opacity)); } -.border-blue-500 { - --tw-border-opacity: 1; - border-color: rgb(59 130 246 / var(--tw-border-opacity)); -} - .border-gray-300 { --tw-border-opacity: 1; border-color: rgb(209 213 219 / var(--tw-border-opacity)); @@ -849,6 +836,11 @@ video { background-color: rgb(59 130 246 / var(--tw-bg-opacity)); } +.bg-blue-600 { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + .bg-gray-200 { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity)); @@ -859,11 +851,21 @@ video { background-color: rgb(209 213 219 / var(--tw-bg-opacity)); } +.bg-gray-900 { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); +} + .bg-red-500 { --tw-bg-opacity: 1; background-color: rgb(239 68 68 / var(--tw-bg-opacity)); } +.bg-red-600 { + --tw-bg-opacity: 1; + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); +} + .p-2 { padding: 0.5rem; } @@ -882,11 +884,6 @@ video { padding-right: 0.5rem; } -.px-8 { - padding-left: 2rem; - padding-right: 2rem; -} - .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; @@ -1009,9 +1006,17 @@ video { display: inline-block; } + .md\:flex { + display: flex; + } + .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .md\:flex-row { + flex-direction: row; + } } @media (min-width: 1024px) { @@ -1037,10 +1042,6 @@ video { } @media (min-width: 1280px) { - .xl\:inline-block { - display: inline-block; - } - .xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } diff --git a/static/styles.min.css b/static/styles.min.css index e7a0414..61afc60 100644 --- a/static/styles.min.css +++ b/static/styles.min.css @@ -1 +1 @@ -/*! tailwindcss v3.4.11 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.static{position:static}.float-left{float:left}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-0{margin-bottom:0}.ml-1{margin-left:.25rem}.flex{display:flex}.grid{display:grid}.hidden{display:none}.aspect-square{aspect-ratio:1/1}.size-full{height:100%;width:100%}.h-60{height:15rem}.h-auto{height:auto}.h-dvh{height:100dvh}.h-full{height:100%}.min-h-0{min-height:0}.w-1\/2{width:50%}.w-2\/6{width:33.333333%}.w-4\/6{width:66.666667%}.w-full{width:100%}.flex-grow,.grow{flex-grow:1}.basis-1\/4{flex-basis:25%}.basis-2\/4{flex-basis:50%}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.auto-cols-max{grid-auto-columns:max-content}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-6{gap:1.5rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-2{row-gap:.5rem}.gap-y-3{row-gap:.75rem}.divide-y-4>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(4px*var(--tw-divide-y-reverse));border-top-width:calc(4px*(1 - var(--tw-divide-y-reverse)))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-4{padding:1rem}.px-16{padding-left:4rem;padding-right:4rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pl-10{padding-left:2.5rem}.pr-6{padding-right:1.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.active\:bg-blue-300:active{--tw-bg-opacity:1;background-color:rgb(147 197 253/var(--tw-bg-opacity))}.active\:bg-gray-100:active{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}@media (min-width:640px){.sm\:inline-block{display:inline-block}.sm\:flex{display:flex}.sm\:flex-1{flex:1 1 0%}.sm\:justify-between{justify-content:space-between}}@media (min-width:768px){.md\:inline-block{display:inline-block}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:inline-block{display:inline-block}.lg\:w-2\/6{width:33.333333%}.lg\:w-4\/6{width:66.666667%}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1280px){.xl\:inline-block{display:inline-block}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1536px){.\32xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}} \ No newline at end of file +/*! tailwindcss v3.4.11 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.static{position:static}.float-left{float:left}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-8{margin-left:2rem;margin-right:2rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-0{margin-bottom:0}.ml-1{margin-left:.25rem}.flex{display:flex}.grid{display:grid}.hidden{display:none}.aspect-square{aspect-ratio:1/1}.size-full{height:100%;width:100%}.h-60{height:15rem}.h-auto{height:auto}.h-dvh{height:100dvh}.h-full{height:100%}.min-h-0{min-height:0}.w-1\/2{width:50%}.w-2\/6{width:33.333333%}.w-4\/6{width:66.666667%}.w-full{width:100%}.flex-grow,.grow{flex-grow:1}.basis-1\/4{flex-basis:25%}.basis-2\/4{flex-basis:50%}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.auto-cols-max{grid-auto-columns:max-content}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-y-2{row-gap:.5rem}.divide-y-4>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(4px*var(--tw-divide-y-reverse));border-top-width:calc(4px*(1 - var(--tw-divide-y-reverse)))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-4{padding:1rem}.px-16{padding-left:4rem;padding-right:4rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pl-10{padding-left:2.5rem}.pr-6{padding-right:1.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.active\:bg-blue-300:active{--tw-bg-opacity:1;background-color:rgb(147 197 253/var(--tw-bg-opacity))}.active\:bg-gray-100:active{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}@media (min-width:640px){.sm\:inline-block{display:inline-block}.sm\:flex{display:flex}.sm\:flex-1{flex:1 1 0%}.sm\:justify-between{justify-content:space-between}}@media (min-width:768px){.md\:inline-block{display:inline-block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}}@media (min-width:1024px){.lg\:inline-block{display:inline-block}.lg\:w-2\/6{width:33.333333%}.lg\:w-4\/6{width:66.666667%}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1536px){.\32xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}} \ No newline at end of file