diff --git a/src/accounting/__init__.py b/src/accounting/__init__.py index 6f1397c..665a551 100644 --- a/src/accounting/__init__.py +++ b/src/accounting/__init__.py @@ -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) diff --git a/src/accounting/report/__init__.py b/src/accounting/report/__init__.py new file mode 100644 index 0000000..cd11237 --- /dev/null +++ b/src/accounting/report/__init__.py @@ -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") diff --git a/src/accounting/report/converters.py b/src/accounting/report/converters.py new file mode 100644 index 0000000..72b0789 --- /dev/null +++ b/src/accounting/report/converters.py @@ -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 diff --git a/src/accounting/report/period.py b/src/accounting/report/period.py new file mode 100644 index 0000000..f4c4857 --- /dev/null +++ b/src/accounting/report/period.py @@ -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) diff --git a/src/accounting/report/period_choosers.py b/src/accounting/report/period_choosers.py new file mode 100644 index 0000000..c730fcc --- /dev/null +++ b/src/accounting/report/period_choosers.py @@ -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) diff --git a/src/accounting/report/report_chooser.py b/src/accounting/report/report_chooser.py new file mode 100644 index 0000000..473f6ca --- /dev/null +++ b/src/accounting/report/report_chooser.py @@ -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) diff --git a/src/accounting/report/report_rows.py b/src/accounting/report/report_rows.py new file mode 100644 index 0000000..d23471c --- /dev/null +++ b/src/accounting/report/report_rows.py @@ -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} diff --git a/src/accounting/report/reports.py b/src/accounting/report/reports.py new file mode 100644 index 0000000..8ba07c3 --- /dev/null +++ b/src/accounting/report/reports.py @@ -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) diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py new file mode 100644 index 0000000..d4ebdc3 --- /dev/null +++ b/src/accounting/report/views.py @@ -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/", 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() diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index 743ad84..4626809 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -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; diff --git a/src/accounting/static/js/period-chooser.js b/src/accounting/static/js/period-chooser.js new file mode 100644 index 0000000..3cf0c65 --- /dev/null +++ b/src/accounting/static/js/period-chooser.js @@ -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"; + } +} diff --git a/src/accounting/static/js/summary-editor.js b/src/accounting/static/js/summary-editor.js index 8688182..b43ac0e 100644 --- a/src/accounting/static/js/summary-editor.js +++ b/src/accounting/static/js/summary-editor.js @@ -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; } diff --git a/src/accounting/static/js/table-row-link.js b/src/accounting/static/js/table-row-link.js new file mode 100644 index 0000000..b05c785 --- /dev/null +++ b/src/accounting/static/js/table-row-link.js @@ -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; + }; + } +} diff --git a/src/accounting/templates/accounting/report/include/period-chooser.html b/src/accounting/templates/accounting/report/include/period-chooser.html new file mode 100644 index 0000000..c570252 --- /dev/null +++ b/src/accounting/templates/accounting/report/include/period-chooser.html @@ -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 +#} + diff --git a/src/accounting/templates/accounting/report/include/report-chooser.html b/src/accounting/templates/accounting/report/include/report-chooser.html new file mode 100644 index 0000000..cec57e0 --- /dev/null +++ b/src/accounting/templates/accounting/report/include/report-chooser.html @@ -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 +#} +
+ + +
diff --git a/src/accounting/templates/accounting/report/journal.html b/src/accounting/templates/accounting/report/journal.html new file mode 100644 index 0000000..0c15e7c --- /dev/null +++ b/src/accounting/templates/accounting/report/journal.html @@ -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 %} + + + +{% endblock %} + +{% block header %}{% block title %}{{ _("Journal %(period)s", period=report.period.desc|title) }}{% endblock %}{% endblock %} + +{% block content %} + +
+ {% if accounting_can_edit() %} + + {% endif %} + {% with report_chooser = report.report_chooser %} + {% include "accounting/report/include/report-chooser.html" %} + {% endwith %} + + + + {{ A_("Download") }} + +
+ +{% with types = report.txn_types %} + {% include "accounting/transaction/include/add-new-material-fab.html" %} +{% endwith %} + +
+ {% with report_chooser = report.report_chooser %} + {% include "accounting/report/include/report-chooser.html" %} + {% endwith %} + +
+ +{% with period = report.period, period_chooser = report.period_chooser %} + {% include "accounting/report/include/period-chooser.html" %} +{% endwith %} + +{% if list %} + {% include "accounting/include/pagination.html" %} + + + + + + + + + + + + + + {% for item in list %} + + + + + + + + + {% endfor %} + +
{{ A_("Date") }}{{ A_("Currency") }}{{ A_("Account") }}{{ A_("Summary") }}{{ A_("Debit") }}{{ A_("Credit") }}
+ +
+ {% for item in list %} + +
+
+
+ {{ item.transaction.date|accounting_format_date }} + {{ item.account.title }} + {% if item.currency_code != accounting_default_currency_code() %} + {{ item.currency.code }} + {% endif %} +
+ {% if item.summary is not none %} +
{{ item.summary }}
+ {% endif %} +
+ +
+ {{ item.amount|accounting_format_amount }} +
+
+
+ {% endfor %} +
+{% else %} +

{{ A_("There is no data.") }}

+{% endif %} + +{% endblock %}