Added the journal report as the first accounting report.
This commit is contained in:
parent
55c2ce6695
commit
9bfcd3c50c
@ -89,4 +89,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
from . import transaction
|
||||
transaction.init_app(app, bp)
|
||||
|
||||
from . import report
|
||||
report.init_app(app, bp)
|
||||
|
||||
app.register_blueprint(bp)
|
||||
|
34
src/accounting/report/__init__.py
Normal file
34
src/accounting/report/__init__.py
Normal file
@ -0,0 +1,34 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The report management.
|
||||
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import PeriodConverter
|
||||
app.url_map.converters["period"] = PeriodConverter
|
||||
|
||||
from .views import bp as report_bp
|
||||
bp.register_blueprint(report_bp, url_prefix="/reports")
|
47
src/accounting/report/converters.py
Normal file
47
src/accounting/report/converters.py
Normal file
@ -0,0 +1,47 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The path converters for the report management.
|
||||
|
||||
"""
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from .period import Period
|
||||
|
||||
|
||||
class PeriodConverter(BaseConverter):
|
||||
"""The supplier converter to convert the period specification from and to
|
||||
the corresponding period in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Period:
|
||||
"""Converts a period specification to a period.
|
||||
|
||||
:param value: The period specification.
|
||||
:return: The corresponding period.
|
||||
"""
|
||||
try:
|
||||
return Period.get_instance(value)
|
||||
except ValueError:
|
||||
abort(404)
|
||||
|
||||
def to_url(self, value: Period) -> str:
|
||||
"""Converts a period to its specification.
|
||||
|
||||
:param value: The period.
|
||||
:return: Its specification.
|
||||
"""
|
||||
return value.spec
|
555
src/accounting/report/period.py
Normal file
555
src/accounting/report/period.py
Normal file
@ -0,0 +1,555 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The date period.
|
||||
|
||||
This file is largely taken from the NanoParma ERP project, first written in
|
||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||
|
||||
"""
|
||||
import calendar
|
||||
import datetime
|
||||
import re
|
||||
import typing as t
|
||||
|
||||
from accounting.locale import gettext
|
||||
|
||||
|
||||
class Period:
|
||||
"""A date period."""
|
||||
|
||||
def __init__(self, start: datetime.date | None, end: datetime.date | None):
|
||||
"""Constructs a new date period.
|
||||
|
||||
:param start: The start date, or None from the very beginning.
|
||||
:param end: The end date, or None till no end.
|
||||
"""
|
||||
self.start: datetime.date | None = start
|
||||
"""The start of the period."""
|
||||
self.end: datetime.date | None = end
|
||||
"""The end of the period."""
|
||||
self.is_default: bool = False
|
||||
"""Whether the is the default period."""
|
||||
self.is_this_month: bool = False
|
||||
"""Whether the period is this month."""
|
||||
self.is_last_month: bool = False
|
||||
"""Whether the period is last month."""
|
||||
self.is_since_last_month: bool = False
|
||||
"""Whether the period is since last month."""
|
||||
self.is_this_year: bool = False
|
||||
"""Whether the period is this year."""
|
||||
self.is_last_year: bool = False
|
||||
"""Whether the period is last year."""
|
||||
self.is_today: bool = False
|
||||
"""Whether the period is today."""
|
||||
self.is_yesterday: bool = False
|
||||
"""Whether the period is yesterday."""
|
||||
self.is_all: bool = start is None and end is None
|
||||
"""Whether the period is all time."""
|
||||
self.spec: str = ""
|
||||
"""The period specification."""
|
||||
self.desc: str = ""
|
||||
"""The text description."""
|
||||
self.is_type_month: bool = False
|
||||
"""Whether the period is for the month chooser."""
|
||||
self.is_a_year: bool = False
|
||||
"""Whether the period is a whole year."""
|
||||
self.is_a_day: bool = False
|
||||
"""Whether the period is a single day."""
|
||||
self._set_properties()
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
"""Sets the following properties.
|
||||
|
||||
* self.spec
|
||||
* self.desc
|
||||
* self.is_a_month
|
||||
* self.is_type_month
|
||||
* self.is_a_year
|
||||
* self.is_a_day
|
||||
|
||||
Override this method to set the properties in the subclasses.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.spec = self.__get_spec()
|
||||
self.desc = self.__get_desc()
|
||||
if self.start is None or self.end is None:
|
||||
return
|
||||
self.is_type_month \
|
||||
= self.start.day == 1 and self.end == _month_end(self.start)
|
||||
self.is_a_year = self.start == datetime.date(self.start.year, 1, 1) \
|
||||
and self.end == datetime.date(self.start.year, 12, 31)
|
||||
self.is_a_day = self.start == self.end
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, spec: str | None = None) -> t.Self:
|
||||
"""Returns a period instance.
|
||||
|
||||
:param spec: The period specification, or omit for the default.
|
||||
:return: The period instance.
|
||||
:raise ValueError: When the period is invalid.
|
||||
"""
|
||||
if spec is None:
|
||||
return ThisMonth()
|
||||
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
|
||||
"this-month": lambda: ThisMonth(),
|
||||
"last-month": lambda: LastMonth(),
|
||||
"since-last-month": lambda: SinceLastMonth(),
|
||||
"this-year": lambda: ThisYear(),
|
||||
"last-year": lambda: LastYear(),
|
||||
"today": lambda: Today(),
|
||||
"yesterday": lambda: Yesterday(),
|
||||
}
|
||||
if spec in named_periods:
|
||||
return named_periods[spec]()
|
||||
start: datetime.date
|
||||
end: datetime.date
|
||||
start, end = _parse_period_spec(spec)
|
||||
if start is not None and end is not None and start > end:
|
||||
raise ValueError
|
||||
return cls(start, end)
|
||||
|
||||
def __get_spec(self) -> str:
|
||||
"""Returns the period specification.
|
||||
|
||||
:return: The period specification.
|
||||
"""
|
||||
if self.start is None:
|
||||
if self.end is None:
|
||||
return "-"
|
||||
else:
|
||||
if self.end.day != _month_end(self.end).day:
|
||||
return "-%04d-%02d-%02d" % (
|
||||
self.end.year, self.end.month, self.end.day)
|
||||
if self.end.month != 12:
|
||||
return "-%04d-%02d" % (self.end.year, self.end.month)
|
||||
return "-%04d" % self.end.year
|
||||
else:
|
||||
if self.end is None:
|
||||
if self.start.day != 1:
|
||||
return "%04d-%02d-%02d-" % (
|
||||
self.start.year, self.start.month, self.start.day)
|
||||
if self.start.month != 1:
|
||||
return "%04d-%02d-" % (self.start.year, self.start.month)
|
||||
return "%04d-" % self.start.year
|
||||
else:
|
||||
try:
|
||||
return self.__get_year_spec()
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return self.__get_month_spec()
|
||||
except ValueError:
|
||||
pass
|
||||
return self.__get_day_spec()
|
||||
|
||||
def __get_year_spec(self) -> str:
|
||||
"""Returns the period specification as a year range.
|
||||
|
||||
:return: The period specification as a year range.
|
||||
:raise ValueError: The period is not a year range.
|
||||
"""
|
||||
if self.start.month != 1 or self.start.day != 1 \
|
||||
or self.end.month != 12 or self.end.day != 31:
|
||||
raise ValueError
|
||||
if self.start.year == self.end.year:
|
||||
return "%04d" % self.start.year
|
||||
return "%04d-%04d" % (self.start.year, self.end.year)
|
||||
|
||||
def __get_month_spec(self) -> str:
|
||||
"""Returns the period specification as a month range.
|
||||
|
||||
:return: The period specification as a month range.
|
||||
:raise ValueError: The period is not a month range.
|
||||
"""
|
||||
if self.start.day != 1 or self.end != month_end(self.end):
|
||||
raise ValueError
|
||||
if self.start.year == self.end.year \
|
||||
and self.start.month == self.end.month:
|
||||
return "%04d-%02d" % (self.start.year, self.start.month)
|
||||
return "%04d-%02d-%04d-%02d" % (
|
||||
self.start.year, self.start.month,
|
||||
self.end.year, self.end.month)
|
||||
|
||||
def __get_day_spec(self) -> str:
|
||||
"""Returns the period specification as a day range.
|
||||
|
||||
:return: The period specification as a day range.
|
||||
:raise ValueError: The period is a month or year range.
|
||||
"""
|
||||
if self.start == self.end:
|
||||
return "%04d-%02d-%02d" % (
|
||||
self.start.year, self.start.month, self.start.day)
|
||||
return "%04d-%02d-%02d-%04d-%02d-%02d" % (
|
||||
self.start.year, self.start.month, self.start.day,
|
||||
self.end.year, self.end.month, self.end.day)
|
||||
|
||||
def __get_desc(self) -> str:
|
||||
"""Returns the period description.
|
||||
|
||||
:return: The period description.
|
||||
"""
|
||||
cls: t.Type[t.Self] = self.__class__
|
||||
if self.start is None:
|
||||
if self.end is None:
|
||||
return gettext("for all time")
|
||||
else:
|
||||
if self.end != _month_end(self.end):
|
||||
return gettext("until %(end)s",
|
||||
end=cls.__format_date(self.end))
|
||||
if self.end.month != 12:
|
||||
return gettext("until %(end)s",
|
||||
end=cls.__format_month(self.end))
|
||||
return gettext("until %(end)s", end=str(self.end.year))
|
||||
else:
|
||||
if self.end is None:
|
||||
if self.start.day != 1:
|
||||
return gettext("since %(start)s",
|
||||
start=cls.__format_date(self.start))
|
||||
if self.start.month != 1:
|
||||
return gettext("since %(start)s",
|
||||
start=cls.__format_month(self.start))
|
||||
return gettext("since %(start)s", start=str(self.start.year))
|
||||
else:
|
||||
try:
|
||||
return self.__get_year_desc()
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return self.__get_month_desc()
|
||||
except ValueError:
|
||||
pass
|
||||
return self.__get_day_desc()
|
||||
|
||||
@staticmethod
|
||||
def __format_date(date: datetime.date) -> str:
|
||||
"""Formats a date.
|
||||
|
||||
:param date: The date.
|
||||
:return: The formatted date.
|
||||
"""
|
||||
return F"{date.year}/{date.month}/{date.day}"
|
||||
|
||||
@staticmethod
|
||||
def __format_month(month: datetime.date) -> str:
|
||||
"""Formats a month.
|
||||
|
||||
:param month: The month.
|
||||
:return: The formatted month.
|
||||
"""
|
||||
return F"{month.year}/{month.month}"
|
||||
|
||||
def __get_year_desc(self) -> str:
|
||||
"""Returns the description as a year range.
|
||||
|
||||
:return: The description as a year range.
|
||||
:raise ValueError: The period is not a year range.
|
||||
"""
|
||||
if self.start.month != 1 or self.start.day != 1 \
|
||||
or self.end.month != 12 or self.end.day != 31:
|
||||
raise ValueError
|
||||
start: str = str(self.start.year)
|
||||
if self.start.year == self.end.year:
|
||||
return gettext("in %(period)s", period=start)
|
||||
end: str = str(self.end.year)
|
||||
return gettext("in %(start)s-%(end)s", start=start, end=end)
|
||||
|
||||
def __get_month_desc(self) -> str:
|
||||
"""Returns the description as a month range.
|
||||
|
||||
:return: The description as a month range.
|
||||
:raise ValueError: The period is not a month range.
|
||||
"""
|
||||
if self.start.day != 1 or self.end != _month_end(self.end):
|
||||
raise ValueError
|
||||
start: str = F"{self.start.year}/{self.start.month}"
|
||||
if self.start.year == self.end.year \
|
||||
and self.start.month == self.end.month:
|
||||
return gettext("in %(period)s", period=start)
|
||||
if self.start.year == self.end.year:
|
||||
end_month: str = str(self.end.month)
|
||||
return gettext("in %(start)s-%(end)s", start=start, end=end_month)
|
||||
end: str = F"{self.end.year}/{self.end.month}"
|
||||
return gettext("in %(start)s-%(end)s", start=start, end=end)
|
||||
|
||||
def __get_day_desc(self) -> str:
|
||||
"""Returns the description as a day range.
|
||||
|
||||
:return: The description as a day range.
|
||||
:raise ValueError: The period is a month or year range.
|
||||
"""
|
||||
start: str = F"{self.start.year}/{self.start.month}/{self.start.day}"
|
||||
if self.start == self.end:
|
||||
return gettext("in %(period)s", period=start)
|
||||
if self.start.year == self.end.year \
|
||||
and self.start.month == self.end.month:
|
||||
end_day: str = str(self.end.day)
|
||||
return gettext("in %(start)s-%(end)s", start=start, end=end_day)
|
||||
if self.start.year == self.end.year:
|
||||
end_month_day: str = F"{self.end.month}/{self.end.day}"
|
||||
return gettext("in %(start)s-%(end)s",
|
||||
start=start, end=end_month_day)
|
||||
end: str = F"{self.end.year}/{self.end.month}/{self.end.day}"
|
||||
return gettext("in %(start)s-%(end)s", start=start, end=end)
|
||||
|
||||
def is_year(self, year: int) -> bool:
|
||||
"""Returns whether the period is the specific year period.
|
||||
|
||||
:param year: The year.
|
||||
:return: True if the period is the year period, or False otherwise.
|
||||
"""
|
||||
if not self.is_a_year:
|
||||
return False
|
||||
return self.start.year == year
|
||||
|
||||
@property
|
||||
def is_type_arbitrary(self) -> bool:
|
||||
"""Returns whether this period is an arbitrary period.
|
||||
|
||||
:return: True if this is an arbitrary period, or False otherwise.
|
||||
"""
|
||||
return not self.is_type_month and not self.is_a_year \
|
||||
and not self.is_a_day
|
||||
|
||||
@property
|
||||
def before(self) -> t.Self | None:
|
||||
"""Returns the period before this period.
|
||||
|
||||
:return: The period before this period.
|
||||
"""
|
||||
if self.start is None:
|
||||
return None
|
||||
return self.__class__(None, self.start - datetime.timedelta(days=1))
|
||||
|
||||
|
||||
class ThisMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: datetime.date = datetime.date.today()
|
||||
this_month_start: datetime.date \
|
||||
= datetime.date(today.year, today.month, 1)
|
||||
super(ThisMonth, self).__init__(this_month_start, _month_end(today))
|
||||
self.is_default = True
|
||||
self.is_this_month = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "this-month"
|
||||
self.desc = gettext("This month")
|
||||
self.is_a_month = True
|
||||
self.is_type_month = True
|
||||
|
||||
|
||||
class LastMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: datetime.date = datetime.date.today()
|
||||
year: int = today.year
|
||||
month: int = today.month - 1
|
||||
if month < 1:
|
||||
year = year - 1
|
||||
month = 12
|
||||
start: datetime.date = datetime.date(year, month, 1)
|
||||
super(LastMonth, self).__init__(start, _month_end(start))
|
||||
self.is_last_month = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "last-month"
|
||||
self.desc = gettext("Last month")
|
||||
self.is_a_month = True
|
||||
self.is_type_month = True
|
||||
|
||||
|
||||
class SinceLastMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: datetime.date = datetime.date.today()
|
||||
year: int = today.year
|
||||
month: int = today.month - 1
|
||||
if month < 1:
|
||||
year = year - 1
|
||||
month = 12
|
||||
start: datetime.date = datetime.date(year, month, 1)
|
||||
super(SinceLastMonth, self).__init__(start, None)
|
||||
self.is_since_last_month = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "since-last-month"
|
||||
self.desc = gettext("Since last month")
|
||||
self.is_type_month = True
|
||||
|
||||
|
||||
class ThisYear(Period):
|
||||
"""The period of this year."""
|
||||
def __init__(self):
|
||||
year: int = datetime.date.today().year
|
||||
start: datetime.date = datetime.date(year, 1, 1)
|
||||
end: datetime.date = datetime.date(year, 12, 31)
|
||||
super(ThisYear, self).__init__(start, end)
|
||||
self.is_this_year = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "this-year"
|
||||
self.desc = gettext("This year")
|
||||
self.is_a_year = True
|
||||
|
||||
|
||||
class LastYear(Period):
|
||||
"""The period of last year."""
|
||||
def __init__(self):
|
||||
year: int = datetime.date.today().year
|
||||
start: datetime.date = datetime.date(year - 1, 1, 1)
|
||||
end: datetime.date = datetime.date(year - 1, 12, 31)
|
||||
super(LastYear, self).__init__(start, end)
|
||||
self.is_last_year = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "last-year"
|
||||
self.desc = gettext("Last year")
|
||||
self.is_a_year = True
|
||||
|
||||
|
||||
class Today(Period):
|
||||
"""The period of today."""
|
||||
def __init__(self):
|
||||
today: datetime.date = datetime.date.today()
|
||||
super(Today, self).__init__(today, today)
|
||||
self.is_this_year = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "today"
|
||||
self.desc = gettext("Today")
|
||||
self.is_a_day = True
|
||||
self.is_today = True
|
||||
|
||||
|
||||
class Yesterday(Period):
|
||||
"""The period of yesterday."""
|
||||
def __init__(self):
|
||||
yesterday: datetime.date \
|
||||
= datetime.date.today() - datetime.timedelta(days=1)
|
||||
super(Yesterday, self).__init__(yesterday, yesterday)
|
||||
self.is_this_year = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "yesterday"
|
||||
self.desc = gettext("Yesterday")
|
||||
self.is_a_day = True
|
||||
self.is_yesterday = True
|
||||
|
||||
|
||||
class TemplatePeriod(Period):
|
||||
"""The period template."""
|
||||
def __init__(self):
|
||||
super(TemplatePeriod, self).__init__(None, None)
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "PERIOD"
|
||||
|
||||
|
||||
class YearPeriod(Period):
|
||||
"""A year period."""
|
||||
def __init__(self, year: int):
|
||||
"""Constructs a year period.
|
||||
|
||||
:param year: The year.
|
||||
"""
|
||||
start: datetime.date = datetime.date(year, 1, 1)
|
||||
end: datetime.date = datetime.date(year, 12, 31)
|
||||
super(YearPeriod, self).__init__(start, end)
|
||||
self.spec = str(year)
|
||||
self.is_a_year = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _parse_period_spec(text: str) \
|
||||
-> tuple[datetime.date | None, datetime.date | None]:
|
||||
"""Parses the period specification.
|
||||
|
||||
:param text: The period specification.
|
||||
:return: The start and end day of the period. The start and end day
|
||||
may be None.
|
||||
:raise ValueError: When the date is invalid.
|
||||
"""
|
||||
if text == "this-month":
|
||||
today: datetime.date = datetime.date.today()
|
||||
return datetime.date(today.year, today.month, 1), _month_end(today)
|
||||
if text == "-":
|
||||
return None, None
|
||||
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
|
||||
if m is not None:
|
||||
return __get_start(m[1], m[2], m[3]), \
|
||||
__get_end(m[1], m[2], m[3])
|
||||
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?-$", text)
|
||||
if m is not None:
|
||||
return __get_start(m[1], m[2], m[3]), None
|
||||
m = re.match(r"-^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
|
||||
if m is not None:
|
||||
return None, __get_end(m[1], m[2], m[3])
|
||||
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?-(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
|
||||
if m is not None:
|
||||
return __get_start(m[1], m[2], m[3]), \
|
||||
__get_end(m[4], m[5], m[6])
|
||||
raise ValueError
|
||||
|
||||
|
||||
def __get_start(year: str, month: str | None, day: str | None)\
|
||||
-> datetime.date:
|
||||
"""Returns the start of the period from the date representation.
|
||||
|
||||
:param year: The year.
|
||||
:param month: The month, if any.
|
||||
:param day: The day, if any.
|
||||
:return: The start of the period.
|
||||
:raise ValueError: When the date is invalid.
|
||||
"""
|
||||
if day is not None:
|
||||
return datetime.date(int(year), int(month), int(day))
|
||||
if month is not None:
|
||||
return datetime.date(int(year), int(month), 1)
|
||||
return datetime.date(int(year), 1, 1)
|
||||
|
||||
|
||||
def __get_end(year: str, month: str | None, day: str | None)\
|
||||
-> datetime.date:
|
||||
"""Returns the end of the period from the date representation.
|
||||
|
||||
:param year: The year.
|
||||
:param month: The month, if any.
|
||||
:param day: The day, if any.
|
||||
:return: The end of the period.
|
||||
:raise ValueError: When the date is invalid.
|
||||
"""
|
||||
if day is not None:
|
||||
return datetime.date(int(year), int(month), int(day))
|
||||
if month is not None:
|
||||
year_n: int = int(year)
|
||||
month_n: int = int(month)
|
||||
day_n: int = calendar.monthrange(year_n, month_n)[1]
|
||||
return datetime.date(year_n, month_n, day_n)
|
||||
return datetime.date(int(year), 12, 31)
|
||||
|
||||
|
||||
def _month_end(date: datetime.date) -> datetime.date:
|
||||
"""Returns the end day of month for a date.
|
||||
|
||||
:param date: The date.
|
||||
:return: The end day of the month of that day.
|
||||
"""
|
||||
day: int = calendar.monthrange(date.year, date.month)[1]
|
||||
return datetime.date(date.year, date.month, day)
|
118
src/accounting/report/period_choosers.py
Normal file
118
src/accounting/report/period_choosers.py
Normal file
@ -0,0 +1,118 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The period choosers.
|
||||
|
||||
This file is largely taken from the NanoParma ERP project, first written in
|
||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from accounting.models import Transaction
|
||||
from .period import YearPeriod, Period, ThisMonth, LastMonth, SinceLastMonth, \
|
||||
ThisYear, LastYear, Today, Yesterday, TemplatePeriod
|
||||
|
||||
|
||||
class PeriodChooser(ABC):
|
||||
"""The period chooser."""
|
||||
|
||||
def __init__(self, start: date | None):
|
||||
"""Constructs a period chooser.
|
||||
|
||||
:param start: The start of the period.
|
||||
"""
|
||||
|
||||
# Shortcut periods
|
||||
self.this_month_url: str = self._url_for(ThisMonth())
|
||||
"""The URL for this month."""
|
||||
self.last_month_url: str = self._url_for(LastMonth())
|
||||
"""The URL for last month."""
|
||||
self.since_last_month_url: str = self._url_for(SinceLastMonth())
|
||||
"""The URL since last mint."""
|
||||
self.this_year_url: str = self._url_for(ThisYear())
|
||||
"""The URL for this year."""
|
||||
self.last_year_url: str = self._url_for(LastYear())
|
||||
"""The URL for last year."""
|
||||
self.today_url: str = self._url_for(Today())
|
||||
"""The URL for today."""
|
||||
self.yesterday_url: str = self._url_for(Yesterday())
|
||||
"""The URL for yesterday."""
|
||||
self.all_url: str = self._url_for(Period(None, None))
|
||||
"""The URL for all period."""
|
||||
self.url_template: str = self._url_for(TemplatePeriod())
|
||||
"""The URL template."""
|
||||
|
||||
# Attributes
|
||||
self.data_start: date | None = start
|
||||
"""The start of the data."""
|
||||
self.has_data: bool = start is not None
|
||||
"""Whether there is any data."""
|
||||
self.has_last_month: bool = False
|
||||
"""Where there is data in last month."""
|
||||
self.has_last_year: bool = False
|
||||
"""Whether there is data in last year."""
|
||||
self.has_yesterday: bool = False
|
||||
"""Whether there is data in yesterday."""
|
||||
self.available_years: t.Iterator[int] = []
|
||||
"""The available years."""
|
||||
|
||||
if self.has_data is not None:
|
||||
today: date = date.today()
|
||||
self.has_last_month = start < date(today.year, today.month, 1)
|
||||
self.has_last_year = start.year < today.year
|
||||
self.has_yesterday = start < today
|
||||
self.available_years: t.Iterator[int] = []
|
||||
if start.year < today.year - 1:
|
||||
self.available_years \
|
||||
= reversed(range(start.year, today.year - 1))
|
||||
|
||||
@abstractmethod
|
||||
def _url_for(self, period: Period) -> str:
|
||||
"""Returns the URL for a period.
|
||||
|
||||
:param period: The period.
|
||||
:return: The URL for the period.
|
||||
"""
|
||||
pass
|
||||
|
||||
def year_url(self, year: int) -> str:
|
||||
"""Returns the period URL of a year.
|
||||
|
||||
:param year: The year
|
||||
:return: The period URL of the year.
|
||||
"""
|
||||
return self._url_for(YearPeriod(year))
|
||||
|
||||
|
||||
class JournalPeriodChooser(PeriodChooser):
|
||||
"""The journal period chooser."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the journal period chooser."""
|
||||
first: Transaction | None \
|
||||
= Transaction.query.order_by(Transaction.date).first()
|
||||
super(JournalPeriodChooser, self).__init__(
|
||||
None if first is None else first.date)
|
||||
|
||||
def _url_for(self, period: Period) -> str:
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.journal-default")
|
||||
return url_for("accounting.report.journal", period=period)
|
108
src/accounting/report/report_chooser.py
Normal file
108
src/accounting/report/report_chooser.py
Normal file
@ -0,0 +1,108 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The report chooser.
|
||||
|
||||
This file is largely taken from the NanoParma ERP project, first written in
|
||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from enum import Enum
|
||||
|
||||
from flask import url_for
|
||||
from flask_babel import LazyString
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency
|
||||
from accounting.template_globals import default_currency_code
|
||||
from .period import Period
|
||||
|
||||
|
||||
class ReportType(Enum):
|
||||
"""The report types."""
|
||||
JOURNAL: str = "journal"
|
||||
"""The journal."""
|
||||
|
||||
|
||||
class ReportLink:
|
||||
"""A link of a report."""
|
||||
|
||||
def __init__(self, name: str | LazyString, url: str):
|
||||
"""Constructs a report.
|
||||
|
||||
:param name: The report name.
|
||||
:param url: The URL.
|
||||
"""
|
||||
self.name: str | LazyString = name
|
||||
"""The report name."""
|
||||
self.url: str = url
|
||||
"""The URL."""
|
||||
self.is_active: bool = False
|
||||
"""Whether the report is the current report."""
|
||||
|
||||
|
||||
class ReportChooser:
|
||||
"""The report chooser."""
|
||||
|
||||
def __init__(self, active_report: ReportType,
|
||||
period: Period | None = None,
|
||||
currency: Currency | None = None):
|
||||
"""Constructs the report chooser.
|
||||
|
||||
:param active_report: The active report.
|
||||
:param period: The period.
|
||||
:param currency: The currency.
|
||||
"""
|
||||
self.__active_report: ReportType = active_report
|
||||
"""The currently active report."""
|
||||
self.__period: Period = Period.get_instance() if period is None \
|
||||
else period
|
||||
"""The period."""
|
||||
self.__currency: Currency = db.session.get(
|
||||
Currency, default_currency_code()) \
|
||||
if currency is None else currency
|
||||
"""The currency."""
|
||||
self.__reports: list[ReportLink] = []
|
||||
"""The links to the reports."""
|
||||
self.__reports.append(self.__journal)
|
||||
self.current_report: str | LazyString = ""
|
||||
"""The name of the current report."""
|
||||
for report in self.__reports:
|
||||
if report.is_active:
|
||||
self.current_report = report.name
|
||||
|
||||
@property
|
||||
def __journal(self) -> ReportLink:
|
||||
"""Returns the journal.
|
||||
|
||||
:return: The journal.
|
||||
"""
|
||||
url: str = url_for("accounting.report.journal-default") \
|
||||
if self.__period.is_default \
|
||||
else url_for("accounting.report.journal", period=self.__period)
|
||||
report = ReportLink(gettext("Journal"), url)
|
||||
if self.__active_report == ReportType.JOURNAL:
|
||||
report.is_active = True
|
||||
return report
|
||||
|
||||
def __iter__(self) -> t.Iterator[ReportLink]:
|
||||
"""Returns the iteration of the reports.
|
||||
|
||||
:return: The iteration of the reports.
|
||||
"""
|
||||
return iter(self.__reports)
|
64
src/accounting/report/report_rows.py
Normal file
64
src/accounting/report/report_rows.py
Normal file
@ -0,0 +1,64 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The rows of the reports.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from decimal import Decimal
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from accounting.models import JournalEntry, Transaction, Account, Currency
|
||||
|
||||
|
||||
class ReportRow(ABC):
|
||||
"""A report row."""
|
||||
|
||||
@abstractmethod
|
||||
def as_dict(self) -> dict[str, t.Any]:
|
||||
"""Returns the row as a dictionary.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
|
||||
|
||||
class JournalRow(ReportRow):
|
||||
"""A row in the journal report."""
|
||||
|
||||
def __init__(self, entry: JournalEntry, transaction: Transaction,
|
||||
account: Account, currency: Currency):
|
||||
"""Constructs the row in the journal report.
|
||||
|
||||
:param entry: The journal entry.
|
||||
:param transaction: The transaction.
|
||||
:param account: The account.
|
||||
:param currency: The currency.
|
||||
"""
|
||||
self.is_debit: bool = entry.is_debit
|
||||
self.summary: str | None = entry.summary
|
||||
self.amount: Decimal = entry.amount
|
||||
self.transaction: Account = transaction
|
||||
self.account: Account = account
|
||||
self.currency: Currency = currency
|
||||
|
||||
def as_dict(self) -> dict[str, t.Any]:
|
||||
return {"date": self.transaction.date.isoformat(),
|
||||
"currency": self.currency.name,
|
||||
"account": str(self.account),
|
||||
"summary": self.summary,
|
||||
"debit": self.amount if self.is_debit else None,
|
||||
"credit": None if self.is_debit else self.amount,
|
||||
"note": self.transaction.note}
|
183
src/accounting/report/reports.py
Normal file
183
src/accounting/report/reports.py
Normal file
@ -0,0 +1,183 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The reports.
|
||||
|
||||
"""
|
||||
import csv
|
||||
from abc import ABC, abstractmethod
|
||||
from io import StringIO
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Response, render_template
|
||||
from flask_sqlalchemy.query import Query
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import JournalEntry, Transaction, Account, Currency
|
||||
from accounting.transaction.dispatcher import TXN_TYPE_OBJ, TransactionTypes
|
||||
from accounting.utils.pagination import Pagination
|
||||
from .period import Period
|
||||
from .period_choosers import PeriodChooser, \
|
||||
JournalPeriodChooser
|
||||
from .report_chooser import ReportChooser, ReportType
|
||||
from .report_rows import ReportRow, JournalRow
|
||||
|
||||
|
||||
class JournalEntryReport(ABC):
|
||||
"""A report based on a journal entry."""
|
||||
|
||||
def __init__(self, period: Period):
|
||||
"""Constructs a journal.
|
||||
|
||||
:param period: The period.
|
||||
"""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self._entries: list[JournalEntry] = self.get_entries()
|
||||
"""The journal entries."""
|
||||
|
||||
@abstractmethod
|
||||
def get_entries(self) -> list[JournalEntry]:
|
||||
"""Returns the journal entries.
|
||||
|
||||
:return: The journal entries.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def entries_to_rows(self, entries: list[JournalEntry]) -> list[ReportRow]:
|
||||
"""Converts the journal entries into report rows.
|
||||
|
||||
:param entries: The journal entries.
|
||||
:return: The report rows.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def csv_field_names(self) -> list[str]:
|
||||
"""Returns the CSV field names.
|
||||
|
||||
:return: The CSV field names.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def csv_filename(self) -> str:
|
||||
"""Returns the CSV file name.
|
||||
|
||||
:return: The CSV file name.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def period_chooser(self) -> PeriodChooser:
|
||||
"""Returns the period chooser.
|
||||
|
||||
:return: The period chooser.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def as_html_page(self) -> str:
|
||||
"""Returns the report as an HTML page.
|
||||
|
||||
:return: The report as an HTML page.
|
||||
"""
|
||||
|
||||
@property
|
||||
def txn_types(self) -> TransactionTypes:
|
||||
"""Returns the transaction types.
|
||||
|
||||
:return: The transaction types.
|
||||
"""
|
||||
return TXN_TYPE_OBJ
|
||||
|
||||
def as_csv_download(self) -> Response:
|
||||
"""Returns the journal entries as CSV download.
|
||||
|
||||
:return: The CSV download response.
|
||||
"""
|
||||
with StringIO() as fp:
|
||||
writer: csv.DictWriter = csv.DictWriter(
|
||||
fp, fieldnames=self.csv_field_names)
|
||||
writer.writeheader()
|
||||
writer.writerows([x.as_dict()
|
||||
for x in self.entries_to_rows(self._entries)])
|
||||
fp.seek(0)
|
||||
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||
response.headers["Content-Disposition"] \
|
||||
= f"attachment; filename={self.csv_filename}"
|
||||
return response
|
||||
|
||||
|
||||
class Journal(JournalEntryReport):
|
||||
"""A journal."""
|
||||
|
||||
def get_entries(self) -> list[JournalEntry]:
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
if self.period.start is not None:
|
||||
conditions.append(Transaction.date >= self.period.start)
|
||||
if self.period.end is not None:
|
||||
conditions.append(Transaction.date <= self.period.end)
|
||||
query: Query = db.session.query(JournalEntry).join(Transaction)
|
||||
if len(conditions) > 0:
|
||||
query = query.filter(*conditions)
|
||||
return query.order_by(Transaction.date,
|
||||
JournalEntry.is_debit.desc(),
|
||||
JournalEntry.no).all()
|
||||
|
||||
def entries_to_rows(self, entries: list[JournalEntry]) -> list[ReportRow]:
|
||||
transactions: dict[int, Transaction] \
|
||||
= {x.id: x for x in Transaction.query.filter(
|
||||
Transaction.id.in_({x.transaction_id for x in entries}))}
|
||||
accounts: dict[int, Account] \
|
||||
= {x.id: x for x in Account.query.filter(
|
||||
Account.id.in_({x.account_id for x in entries}))}
|
||||
currencies: dict[int, Currency] \
|
||||
= {x.code: x for x in Currency.query.filter(
|
||||
Currency.code.in_({x.currency_code for x in entries}))}
|
||||
return [JournalRow(x, transactions[x.transaction_id],
|
||||
accounts[x.account_id], currencies[x.currency_code])
|
||||
for x in entries]
|
||||
|
||||
@property
|
||||
def csv_field_names(self) -> list[str]:
|
||||
return ["date", "currency", "account", "summary", "debit", "credit",
|
||||
"note"]
|
||||
|
||||
@property
|
||||
def csv_filename(self) -> str:
|
||||
return f"journal-{self.period.spec}.csv"
|
||||
|
||||
@property
|
||||
def period_chooser(self) -> PeriodChooser:
|
||||
return JournalPeriodChooser()
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
return ReportChooser(ReportType.JOURNAL, self.period)
|
||||
|
||||
def as_html_page(self) -> str:
|
||||
pagination: Pagination = Pagination[JournalEntry](self._entries)
|
||||
return render_template("accounting/report/journal.html",
|
||||
list=self.entries_to_rows(pagination.list),
|
||||
pagination=pagination, report=self)
|
60
src/accounting/report/views.py
Normal file
60
src/accounting/report/views.py
Normal file
@ -0,0 +1,60 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the report management.
|
||||
|
||||
"""
|
||||
from flask import Blueprint, request, Response
|
||||
|
||||
from accounting.utils.permission import has_permission, can_view
|
||||
from .period import Period
|
||||
from .reports import Journal
|
||||
|
||||
bp: Blueprint = Blueprint("report", __name__)
|
||||
"""The view blueprint for the reports."""
|
||||
|
||||
|
||||
@bp.get("journal", endpoint="journal-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_journal_list() -> str | Response:
|
||||
"""Returns the journal.
|
||||
|
||||
:return: The journal in the period.
|
||||
"""
|
||||
return __get_journal_list(Period.get_instance())
|
||||
|
||||
|
||||
@bp.get("journal/<period:period>", endpoint="journal")
|
||||
@has_permission(can_view)
|
||||
def get_journal_list(period: Period) -> str | Response:
|
||||
"""Returns the journal.
|
||||
|
||||
:param period: The period.
|
||||
:return: The journal in the period.
|
||||
"""
|
||||
return __get_journal_list(period)
|
||||
|
||||
|
||||
def __get_journal_list(period: Period) -> str | Response:
|
||||
"""Returns journal.
|
||||
|
||||
:param period: The period.
|
||||
:return: The journal in the period.
|
||||
"""
|
||||
report: Journal = Journal(period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.as_csv_download()
|
||||
return report.as_html_page()
|
@ -109,6 +109,20 @@
|
||||
border-top: thick double slategray;
|
||||
}
|
||||
|
||||
/* The accounting report */
|
||||
.accounting-ledger-table thead {
|
||||
border-bottom: 1px double black;
|
||||
}
|
||||
.accounting-amount {
|
||||
text-align: right;
|
||||
}
|
||||
td.accounting-amount {
|
||||
font-style: italic;
|
||||
}
|
||||
.accounting-mobile-journal-credit {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
/* The Material Design text field (floating form control in Bootstrap) */
|
||||
.accounting-material-text-field {
|
||||
position: relative;
|
||||
|
399
src/accounting/static/js/period-chooser.js
Normal file
399
src/accounting/static/js/period-chooser.js
Normal file
@ -0,0 +1,399 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* period-chooser.js: The JavaScript for the period chooser
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/3/4
|
||||
*/
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
new PeriodChooser();
|
||||
});
|
||||
|
||||
/**
|
||||
* The period chooser.
|
||||
*
|
||||
*/
|
||||
class PeriodChooser {
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
|
||||
/**
|
||||
* The modal of the period chooser
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
modal;
|
||||
|
||||
/**
|
||||
* The tab planes
|
||||
* @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
|
||||
*/
|
||||
tabPlanes = {};
|
||||
|
||||
/**
|
||||
* Constructs the period chooser.
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.prefix = "accounting-period-chooser";
|
||||
this.modal = document.getElementById(this.prefix + "-modal");
|
||||
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
|
||||
const tab = new cls(this);
|
||||
this.tabPlanes[tab.tabId()] = tab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab plane.
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
*/
|
||||
class TabPlane {
|
||||
|
||||
/**
|
||||
* The period chooser
|
||||
* @type {PeriodChooser}
|
||||
*/
|
||||
chooser;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
|
||||
/**
|
||||
* The tab
|
||||
* @type {HTMLSpanElement}
|
||||
*/
|
||||
#tab;
|
||||
|
||||
/**
|
||||
* The page
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#page;
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
* @param chooser {PeriodChooser} the period chooser
|
||||
*/
|
||||
constructor(chooser) {
|
||||
this.chooser = chooser;
|
||||
this.prefix = "accounting-period-chooser-" + this.tabId();
|
||||
this.#tab = document.getElementById(this.prefix + "-tab");
|
||||
this.#page = document.getElementById(this.prefix + "-page");
|
||||
this.#tab.onclick = () => this.#switchToMe();
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
* @abstract
|
||||
*/
|
||||
tabId() { throw new Error("Method not implemented.") };
|
||||
|
||||
/**
|
||||
* Switches to the tab plane.
|
||||
*
|
||||
*/
|
||||
#switchToMe() {
|
||||
for (const tabPlane of Object.values(this.chooser.tabPlanes)) {
|
||||
tabPlane.#tab.classList.remove("active")
|
||||
tabPlane.#tab.ariaCurrent = "false";
|
||||
tabPlane.#page.classList.add("d-none");
|
||||
tabPlane.#page.ariaCurrent = "false";
|
||||
}
|
||||
this.#tab.classList.add("active");
|
||||
this.#tab.ariaCurrent = "page";
|
||||
this.#page.classList.remove("d-none");
|
||||
this.#page.ariaCurrent = "page";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The month tab plane.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class MonthTab extends TabPlane {
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
* @param chooser {PeriodChooser} the period chooser
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
const monthChooser = document.getElementById(this.prefix + "-chooser");
|
||||
let start = monthChooser.dataset.start;
|
||||
new tempusDominus.TempusDominus(monthChooser, {
|
||||
restrictions: {
|
||||
minDate: start,
|
||||
},
|
||||
display: {
|
||||
inline: true,
|
||||
components: {
|
||||
date: false,
|
||||
clock: false,
|
||||
},
|
||||
},
|
||||
defaultDate: monthChooser.dataset.default,
|
||||
});
|
||||
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||
const date = e.detail.date;
|
||||
const year = date.year;
|
||||
const month = date.month + 1;
|
||||
const period = month < 10? year + "-0" + month: year + "-" + month;
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", period);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
tabId() {
|
||||
return "month";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The year tab plane.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class YearTab extends TabPlane {
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
tabId() {
|
||||
return "year";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The day tab plane.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class DayTab extends TabPlane {
|
||||
|
||||
/**
|
||||
* The day input
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#date;
|
||||
|
||||
/**
|
||||
* The error of the date input
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#dateError;
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
* @param chooser {PeriodChooser} the period chooser
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
this.#date = document.getElementById(this.prefix + "-date");
|
||||
this.#dateError = document.getElementById(this.prefix + "-date-error");
|
||||
this.#date.onchange = () => {
|
||||
if (this.#validateDate()) {
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", this.#date.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the date.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateDate() {
|
||||
if (this.#date.value === "") {
|
||||
this.#date.classList.add("is-invalid");
|
||||
this.#dateError.innerText = A_("Please fill in the date.");
|
||||
return false;
|
||||
}
|
||||
if (this.#date.value < this.#date.min) {
|
||||
this.#date.classList.add("is-invalid");
|
||||
this.#dateError.innerText = A_("The date is too early.");
|
||||
return false;
|
||||
}
|
||||
this.#date.classList.remove("is-invalid");
|
||||
this.#dateError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
tabId() {
|
||||
return "day";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom tab plane.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class CustomTab extends TabPlane {
|
||||
|
||||
/**
|
||||
* The start of the period
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#start;
|
||||
|
||||
/**
|
||||
* The error of the start
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#startError;
|
||||
|
||||
/**
|
||||
* The end of the period
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#end;
|
||||
|
||||
/**
|
||||
* The error of the end
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#endError;
|
||||
|
||||
/**
|
||||
* The confirm button
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#conform;
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
* @param chooser {PeriodChooser} the period chooser
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
this.#start = document.getElementById(this.prefix + "-start");
|
||||
this.#startError = document.getElementById(this.prefix + "-start-error");
|
||||
this.#end = document.getElementById(this.prefix + "-end");
|
||||
this.#endError = document.getElementById(this.prefix + "-end-error");
|
||||
this.#conform = document.getElementById(this.prefix + "-confirm");
|
||||
this.#start.onchange = () => {
|
||||
if (this.#validateStart()) {
|
||||
this.#end.min = this.#start.value;
|
||||
}
|
||||
};
|
||||
this.#end.onchange = () => {
|
||||
if (this.#validateEnd()) {
|
||||
this.#start.max = this.#end.value;
|
||||
}
|
||||
};
|
||||
this.#conform.onclick = () => {
|
||||
let isValid = true;
|
||||
isValid = this.#validateStart() && isValid;
|
||||
isValid = this.#validateEnd() && isValid;
|
||||
if (isValid) {
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", this.#start.value + "-" + this.#end.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the start of the period.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
#validateStart() {
|
||||
if (this.#start.value === "") {
|
||||
this.#start.classList.add("is-invalid");
|
||||
this.#startError.innerText = A_("Please fill in the start date.");
|
||||
return false;
|
||||
}
|
||||
if (this.#start.value < this.#start.min) {
|
||||
this.#start.classList.add("is-invalid");
|
||||
this.#startError.innerText = A_("The start date is too early.");
|
||||
return false;
|
||||
}
|
||||
if (this.#start.value > this.#start.max) {
|
||||
this.#start.classList.add("is-invalid");
|
||||
this.#startError.innerText = A_("The start date cannot be beyond the end date.");
|
||||
return false;
|
||||
}
|
||||
this.#start.classList.remove("is-invalid");
|
||||
this.#startError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the end of the period.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
#validateEnd() {
|
||||
this.#end.value = this.#end.value.trim();
|
||||
if (this.#end.value === "") {
|
||||
this.#end.classList.add("is-invalid");
|
||||
this.#endError.innerText = A_("Please fill in the end date.");
|
||||
return false;
|
||||
}
|
||||
if (this.#end.value < this.#end.min) {
|
||||
this.#end.classList.add("is-invalid");
|
||||
this.#endError.innerText = A_("The end date cannot be beyond the start date.");
|
||||
return false;
|
||||
}
|
||||
this.#end.classList.remove("is-invalid");
|
||||
this.#endError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
tabId() {
|
||||
return "custom";
|
||||
}
|
||||
}
|
@ -122,12 +122,6 @@ class SummaryEditor {
|
||||
*/
|
||||
#formSummary;
|
||||
|
||||
/**
|
||||
* The tab plane classes
|
||||
* @type {typeof TabPlane[]}
|
||||
*/
|
||||
#TAB_CLASES = [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]
|
||||
|
||||
/**
|
||||
* The tab planes
|
||||
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
|
||||
@ -157,7 +151,7 @@ class SummaryEditor {
|
||||
this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||
this.#formSummary = document.getElementById("accounting-entry-form-summary");
|
||||
|
||||
for (const cls of this.#TAB_CLASES) {
|
||||
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
|
||||
const tab = new cls(this);
|
||||
this.tabPlanes[tab.tabId()] = tab;
|
||||
}
|
||||
|
41
src/accounting/static/js/table-row-link.js
Normal file
41
src/accounting/static/js/table-row-link.js
Normal file
@ -0,0 +1,41 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* table-row-link.js: The JavaScript for table rows as links.
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/3/4
|
||||
*/
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeTableRowLinks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes the table rows as links.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeTableRowLinks() {
|
||||
const rows = Array.from(document.getElementsByClassName("accounting-clickable accounting-table-row-link"));
|
||||
for (const row of rows) {
|
||||
row.onclick = () => {
|
||||
window.location = row.dataset.href;
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,150 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
period-chooser.html: The period chooser
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/4
|
||||
#}
|
||||
<div id="accounting-period-chooser-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-period-chooser-modal-label" aria-hidden="true" data-url-template="{{ period_chooser.url_template }}">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="accounting-period-chooser-modal-label">{{ A_("Period Chooser") }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{# Tab navigation #}
|
||||
<ul class="nav nav-tabs mb-2">
|
||||
<li class="nav-item">
|
||||
<span id="accounting-period-chooser-month-tab" class="nav-link {% if period.is_type_month %} active {% endif %} accounting-clickable" aria-current="{% if period.is_type_month %} page {% else %} false {% endif %}" data-tab-id="month">
|
||||
{{ A_("Month") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-period-chooser-year-tab" class="nav-link {% if period.is_a_year %} active {% endif %} accounting-clickable" aria-current="{% if period.is_a_year %} page {% else %} false {% endif %}" data-tab-id="year">
|
||||
{{ A_("Year") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-period-chooser-day-tab" class="nav-link {% if period.is_a_day %} active {% endif %} accounting-clickable" aria-current="{% if period.is_a_day %} page {% else %} false {% endif %}" data-tab-id="day">
|
||||
{{ A_("Day") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-period-chooser-custom-tab" class="nav-link {% if period.is_type_arbitrary %} active {% endif %} accounting-clickable" aria-current="{% if period.is_type_arbitrary %} page {% else %} false {% endif %}" data-tab-id="custom">
|
||||
{{ A_("Custom") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{# The month periods #}
|
||||
<div id="accounting-period-chooser-month-page" {% if period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
|
||||
<div>
|
||||
<a class="btn {% if period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.this_month_url }}">
|
||||
{{ A_("This month") }}
|
||||
</a>
|
||||
{% if period_chooser.has_last_month %}
|
||||
<a class="btn {% if period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.last_month_url }}">
|
||||
{{ A_("Last month") }}
|
||||
</a>
|
||||
<a class="btn {% if period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.since_last_month_url }}">
|
||||
{{ A_("Since last month") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if period_chooser.has_data %}
|
||||
<div id="accounting-period-chooser-month-chooser" class="mt-3" data-start="{{ period_chooser.data_start }}" data-default="{{ period.start }}"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# The year periods #}
|
||||
<div id="accounting-period-chooser-year-page" {% if period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
|
||||
<a class="btn {% if period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.this_year_url }}">
|
||||
{{ A_("This year") }}
|
||||
</a>
|
||||
{% if period_chooser.has_last_year %}
|
||||
<a class="btn {% if period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.last_year_url }}">
|
||||
{{ A_("Last year") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if period_chooser.available_years %}
|
||||
<ul class="nav nav-pills mt-3">
|
||||
{% for year in period_chooser.available_years %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if period.is_year(year) %} active {% endif %}" href="{{ period_chooser.year_url(year) }}">{{ year }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# The day periods #}
|
||||
<div id="accounting-period-chooser-day-page" {% if period.is_a_day %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-day-tab">
|
||||
<div>
|
||||
<a class="btn {% if period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.today_url }}">
|
||||
{{ A_("Today") }}
|
||||
</a>
|
||||
{% if period_chooser.has_yesterday %}
|
||||
<a class="btn {% if period.is_yesterday %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.yesterday_url }}">
|
||||
{{ A_("Yesterday") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if period_chooser.has_data %}
|
||||
<div class="mt-3">
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-period-chooser-day-date" class="form-control" type="date" value="{{ "" if period.start is none else period.start }}" min="{{ period_chooser.data_start }}" required="required">
|
||||
<label for="accounting-period-chooser-day-date" class="form-label">{{ A_("Date") }}</label>
|
||||
<div id="accounting-period-chooser-day-date-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# The custom periods #}
|
||||
<div id="accounting-period-chooser-custom-page" {% if period.is_type_arbitrary %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-custom-tab">
|
||||
<div>
|
||||
<a class="btn {% if period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.all_url }}">
|
||||
{{ A_("All") }}
|
||||
</a>
|
||||
</div>
|
||||
{% if period_chooser.has_data %}
|
||||
<div class="mt-3">
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-period-chooser-custom-start" class="form-control" type="date" value="{{ "" if period.start is none else period.start }}" min="{{ period_chooser.data_start }}" max="{{ period.end }}" required="required">
|
||||
<label for="accounting-period-chooser-custom-start" class="form-label">{{ A_("From") }}</label>
|
||||
<div id="accounting-period-chooser-custom-start-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-period-chooser-custom-end" class="form-control" type="date" value="{{ "" if period.end is none else period.end }}" min="{{ period.start }}" required="required">
|
||||
<label for="accounting-period-chooser-custom-end" class="form-label">{{ A_("To") }}</label>
|
||||
<div id="accounting-period-chooser-custom-end-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button id="accounting-period-chooser-custom-confirm" class="btn btn-primary" type="submit">
|
||||
{{ A_("Confirm") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,33 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
report-chooser.html: The report chooser
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/4
|
||||
#}
|
||||
<div class="btn-group" role="group">
|
||||
<button id="accounting-report-chooser" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-book"></i>
|
||||
<span class="d-none d-md-inline">{{ report_chooser.current_report }}</span>
|
||||
<span class="d-md-none">{{ A_("Report") }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="accounting-report-chooser">
|
||||
{% for report in report_chooser %}
|
||||
<li><a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">{{ report.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
146
src/accounting/templates/accounting/report/journal.html
Normal file
146
src/accounting/templates/accounting/report/journal.html
Normal file
@ -0,0 +1,146 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
journal.html: The journal
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/4
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/table-row-link.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ _("Journal %(period)s", period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
{{ A_("New") }}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.expense)|accounting_append_next }}">
|
||||
{{ A_("Cash Expense") }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.income)|accounting_append_next }}">
|
||||
{{ A_("Cash Income") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.transfer)|accounting_append_next }}">
|
||||
{{ A_("Transfer") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% with report_chooser = report.report_chooser %}
|
||||
{% include "accounting/report/include/report-chooser.html" %}
|
||||
{% endwith %}
|
||||
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
|
||||
<i class="fa-solid fa-calendar-day"></i>
|
||||
{{ report.period.desc }}
|
||||
</button>
|
||||
<a class="btn btn-primary" role="button" href="?as=csv">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
{{ A_("Download") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% with types = report.txn_types %}
|
||||
{% include "accounting/transaction/include/add-new-material-fab.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="btn-group btn-actions mb-3 d-md-none">
|
||||
{% with report_chooser = report.report_chooser %}
|
||||
{% include "accounting/report/include/report-chooser.html" %}
|
||||
{% endwith %}
|
||||
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
|
||||
<i class="fa-solid fa-calendar-day"></i>
|
||||
{{ A_("Period") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% with period = report.period, period_chooser = report.period_chooser %}
|
||||
{% include "accounting/report/include/period-chooser.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% if list %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
|
||||
<table class="table table-striped table-hover d-none d-md-table accounting-ledger-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ A_("Date") }}</th>
|
||||
<th scope="col">{{ A_("Currency") }}</th>
|
||||
<th scope="col">{{ A_("Account") }}</th>
|
||||
<th scope="col">{{ A_("Summary") }}</th>
|
||||
<th class="accounting-amount" scope="col">{{ A_("Debit") }}</th>
|
||||
<th class="accounting-amount" scope="col">{{ A_("Credit") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in list %}
|
||||
<tr class="accounting-clickable accounting-table-row-link" data-href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
||||
<td>{{ item.transaction.date|accounting_format_date }}</td>
|
||||
<td>{{ item.currency.name }}</td>
|
||||
<td>{{ item.account.title }}</td>
|
||||
<td>{{ "" if item.summary is none else item.summary }}</td>
|
||||
<td class="accounting-amount">{{ "" if not item.is_debit else item.amount|accounting_format_amount }}</td>
|
||||
<td class="accounting-amount">{{ "" if item.is_debit else item.amount|accounting_format_amount }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="list-group d-md-none">
|
||||
{% for item in list %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div {% if not item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||
<div class="text-muted small">
|
||||
{{ item.transaction.date|accounting_format_date }}
|
||||
{{ item.account.title }}
|
||||
{% if item.currency_code != accounting_default_currency_code() %}
|
||||
<span class="badge rounded-pill bg-info">{{ item.currency.code }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.summary is not none %}
|
||||
<div>{{ item.summary }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="badge rounded-pill bg-info">{{ item.amount|accounting_format_amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
Loading…
Reference in New Issue
Block a user