Compare commits
7
Commits
4dd08624f7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0feeaad4fa | ||
|
|
5b15915f0b | ||
|
|
1a4f35c102 | ||
|
|
1f3bb16dbc | ||
|
|
d66d52bc65 | ||
|
|
55f8e929c4 | ||
|
|
961f52ea82 |
@@ -0,0 +1,72 @@
|
||||
name: Build and publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
releases: write
|
||||
|
||||
env:
|
||||
REGISTRY: git.spyk3r.com
|
||||
IMAGE_NAME: spyk3r/timerr
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and publish image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
|
||||
- name: Create Gitea release
|
||||
shell: bash
|
||||
env:
|
||||
API_URL: ${{ gitea.api_url }}
|
||||
REPOSITORY: ${{ gitea.repository }}
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tag="build-${COMMIT_SHA}"
|
||||
release_url="${API_URL}/repos/${REPOSITORY}/releases"
|
||||
|
||||
status=$(curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${release_url}/tags/${tag}")
|
||||
|
||||
if [ "${status}" = "200" ]; then
|
||||
echo "Release ${tag} already exists"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${status}" != "404" ]; then
|
||||
echo "Unable to check release ${tag}: HTTP ${status}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --fail-with-body --request POST \
|
||||
--header "Authorization: token ${GITEA_TOKEN}" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "{\"tag_name\":\"${tag}\",\"target_commitish\":\"${COMMIT_SHA}\",\"name\":\"Build ${COMMIT_SHA}\",\"body\":\"Docker image: ${REGISTRY}/${IMAGE_NAME}:${COMMIT_SHA}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"${release_url}"
|
||||
@@ -22,3 +22,39 @@ py -3.12 -m venv .venv
|
||||
.\.venv\Scripts\python.exe manage.py check
|
||||
.\.venv\Scripts\python.exe -m pytest
|
||||
```
|
||||
|
||||
## Docker image
|
||||
|
||||
При каждом push в ветку `main` Gitea Actions собирает образ и публикует его в
|
||||
Container Registry, а затем создаёт Gitea Release с тегом
|
||||
`build-<commit-sha>`:
|
||||
|
||||
```text
|
||||
git.spyk3r.com/spyk3r/timerr:latest
|
||||
git.spyk3r.com/spyk3r/timerr:<commit-sha>
|
||||
```
|
||||
|
||||
Для публикации образа добавьте в `Settings → Actions → Secrets` два секрета:
|
||||
|
||||
- `REGISTRY_USERNAME` — имя пользователя Gitea, которому принадлежит token;
|
||||
- `REGISTRY_TOKEN` — personal access token с разрешением `package: Read and Write`.
|
||||
|
||||
Встроенный `GITEA_TOKEN` используется только для создания release. Для него в
|
||||
`Settings → Actions → General` должна быть разрешена запись в releases.
|
||||
|
||||
Для запуска приватного образа на VPS сначала войдите в registry с помощью
|
||||
логина Gitea и personal access token с правом чтения packages:
|
||||
|
||||
```bash
|
||||
docker login git.spyk3r.com
|
||||
docker pull git.spyk3r.com/spyk3r/timerr:latest
|
||||
docker run -d --name timerr --restart unless-stopped \
|
||||
-p 8000:8000 \
|
||||
-e DEBUG=0 \
|
||||
-e SECRET_KEY='replace-with-a-long-random-secret' \
|
||||
-e ALLOWED_HOSTS='your-domain.example' \
|
||||
-e CSRF_TRUSTED_ORIGINS='https://your-domain.example' \
|
||||
-e DATABASE_NAME=/app/data/timerr.sqlite3 \
|
||||
-v timerr-data:/app/data \
|
||||
git.spyk3r.com/spyk3r/timerr:latest
|
||||
```
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your tests here.
|
||||
class HomeRedirectTests(TestCase):
|
||||
def test_root_redirects_to_task_list(self):
|
||||
response = self.client.get("/")
|
||||
self.assertRedirects(response, reverse("task-list"), fetch_redirect_response=False)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", RedirectView.as_view(pattern_name="task-list", permanent=False), name="home"),
|
||||
path("login/", auth_views.LoginView.as_view(template_name="accounts/login.html"), name="login"),
|
||||
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||
path("password/change/", views.password_change, name="password-change"),
|
||||
|
||||
+86
-12
@@ -12,16 +12,27 @@ from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
|
||||
def _font_name():
|
||||
def _font_names():
|
||||
candidates = (
|
||||
Path("C:/Windows/Fonts/arial.ttf"),
|
||||
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
|
||||
(Path("C:/Windows/Fonts/arial.ttf"), Path("C:/Windows/Fonts/arialbd.ttf")),
|
||||
(
|
||||
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
|
||||
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||
),
|
||||
)
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
pdfmetrics.registerFont(TTFont("TimerrFont", path))
|
||||
return "TimerrFont"
|
||||
return "Helvetica"
|
||||
for regular_path, bold_path in candidates:
|
||||
if regular_path.exists() and bold_path.exists():
|
||||
pdfmetrics.registerFont(TTFont("TimerrFont", regular_path))
|
||||
pdfmetrics.registerFont(TTFont("TimerrFont-Bold", bold_path))
|
||||
pdfmetrics.registerFontFamily(
|
||||
"TimerrFont",
|
||||
normal="TimerrFont",
|
||||
bold="TimerrFont-Bold",
|
||||
italic="TimerrFont",
|
||||
boldItalic="TimerrFont-Bold",
|
||||
)
|
||||
return "TimerrFont", "TimerrFont-Bold"
|
||||
return "Helvetica", "Helvetica-Bold"
|
||||
|
||||
|
||||
def _duration(minutes):
|
||||
@@ -31,11 +42,13 @@ def _duration(minutes):
|
||||
|
||||
def render_company_pdf(report, company):
|
||||
output = BytesIO()
|
||||
font = _font_name()
|
||||
font, bold_font = _font_names()
|
||||
styles = getSampleStyleSheet()
|
||||
normal = ParagraphStyle("TimerrNormal", parent=styles["Normal"], fontName=font, fontSize=9, leading=12)
|
||||
heading = ParagraphStyle("TimerrHeading", parent=styles["Heading1"], fontName=font, fontSize=18, leading=22)
|
||||
subheading = ParagraphStyle("TimerrSubheading", parent=styles["Heading2"], fontName=font, fontSize=13, leading=16)
|
||||
heading = ParagraphStyle("TimerrHeading", parent=styles["Heading1"], fontName=bold_font, fontSize=18, leading=22)
|
||||
subheading = ParagraphStyle("TimerrSubheading", parent=styles["Heading2"], fontName=bold_font, fontSize=13, leading=16)
|
||||
task_title = ParagraphStyle("TimerrTaskTitle", parent=normal, fontName=bold_font, fontSize=9, leading=11, textColor=colors.HexColor("#172033"))
|
||||
task_description = ParagraphStyle("TimerrTaskDescription", parent=normal, fontSize=8, leading=11, textColor=colors.HexColor("#5a6477"))
|
||||
total = ParagraphStyle("TimerrTotal", parent=normal, fontSize=12, alignment=TA_RIGHT)
|
||||
document = SimpleDocTemplate(output, pagesize=A4, rightMargin=15 * mm, leftMargin=15 * mm, topMargin=15 * mm, bottomMargin=15 * mm)
|
||||
story = [
|
||||
@@ -53,13 +66,18 @@ def render_company_pdf(report, company):
|
||||
for task in project_group["tasks"]:
|
||||
rows.append([
|
||||
task.work_date.strftime("%d.%m.%Y"),
|
||||
Paragraph(f"<b>{escape(task.title)}</b><br/>{escape(task.description)}", normal),
|
||||
[
|
||||
Paragraph(escape(task.title), task_title),
|
||||
Spacer(1, 1.5 * mm),
|
||||
Paragraph(escape(task.description), task_description),
|
||||
],
|
||||
_duration(task.duration_minutes), f"{task.hourly_rate} ₽", f"{task.amount} ₽",
|
||||
])
|
||||
rows.append(["", "Итого по проекту", _duration(project_group["minutes"]), "", f"{project_group['amount']} ₽"])
|
||||
table = Table(rows, colWidths=(22 * mm, 78 * mm, 25 * mm, 25 * mm, 25 * mm), repeatRows=1)
|
||||
table.setStyle(TableStyle([
|
||||
("FONTNAME", (0, 0), (-1, -1), font), ("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("FONTNAME", (0, 0), (-1, 0), bold_font),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e9edf4")),
|
||||
("GRID", (0, 0), (-1, -1), .35, colors.HexColor("#aeb7c5")),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"), ("FONTNAME", (0, -1), (-1, -1), font),
|
||||
@@ -69,3 +87,59 @@ def render_company_pdf(report, company):
|
||||
story.append(Paragraph(f"Итого: {_duration(company_group['minutes'])}, {company_group['amount']} ₽", total))
|
||||
document.build(story)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def render_user_pdf(report):
|
||||
output = BytesIO()
|
||||
font, bold_font = _font_names()
|
||||
styles = getSampleStyleSheet()
|
||||
normal = ParagraphStyle("UserReportNormal", parent=styles["Normal"], fontName=font, fontSize=9, leading=12)
|
||||
heading = ParagraphStyle("UserReportHeading", parent=styles["Heading1"], fontName=bold_font, fontSize=18, leading=22)
|
||||
company_heading = ParagraphStyle("UserReportCompany", parent=styles["Heading2"], fontName=bold_font, fontSize=15, leading=18, textColor=colors.HexColor("#172033"))
|
||||
project_heading = ParagraphStyle("UserReportProject", parent=styles["Heading3"], fontName=bold_font, fontSize=12, leading=15)
|
||||
task_title = ParagraphStyle("UserReportTaskTitle", parent=normal, fontName=bold_font, fontSize=9, leading=11)
|
||||
task_description = ParagraphStyle("UserReportTaskDescription", parent=normal, fontSize=8, leading=11, textColor=colors.HexColor("#5a6477"))
|
||||
total = ParagraphStyle("UserReportTotal", parent=normal, fontName=bold_font, fontSize=11, alignment=TA_RIGHT)
|
||||
grand_total = ParagraphStyle("UserReportGrandTotal", parent=total, fontSize=14, leading=18)
|
||||
document = SimpleDocTemplate(output, pagesize=A4, rightMargin=15 * mm, leftMargin=15 * mm, topMargin=15 * mm, bottomMargin=15 * mm)
|
||||
user_name = report["user"].get_full_name() or report["user"].username
|
||||
story = [
|
||||
Paragraph("Общий отчёт о выполненных работах", heading),
|
||||
Spacer(1, 4 * mm),
|
||||
Paragraph(f"<b>Исполнитель:</b> {escape(user_name)}", normal),
|
||||
Paragraph(f"<b>Период:</b> {report['month']:02d}.{report['year']}", normal),
|
||||
Spacer(1, 5 * mm),
|
||||
]
|
||||
for company_group in report["companies"]:
|
||||
story.append(Paragraph(escape(company_group["company"].name), company_heading))
|
||||
for project_group in company_group["projects"].values():
|
||||
story.append(Paragraph(escape(project_group["project"].name), project_heading))
|
||||
rows = [["Дата", "Задача и описание", "Время", "Ставка", "Сумма"]]
|
||||
for task in project_group["tasks"]:
|
||||
rows.append([
|
||||
task.work_date.strftime("%d.%m.%Y"),
|
||||
[
|
||||
Paragraph(escape(task.title), task_title),
|
||||
Spacer(1, 1.5 * mm),
|
||||
Paragraph(escape(task.description), task_description),
|
||||
],
|
||||
_duration(task.duration_minutes), f"{task.hourly_rate} ₽", f"{task.amount} ₽",
|
||||
])
|
||||
rows.append(["", "Итого по проекту", _duration(project_group["minutes"]), "", f"{project_group['amount']} ₽"])
|
||||
table = Table(rows, colWidths=(22 * mm, 78 * mm, 25 * mm, 25 * mm, 25 * mm), repeatRows=1)
|
||||
table.setStyle(TableStyle([
|
||||
("FONTNAME", (0, 0), (-1, -1), font), ("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("FONTNAME", (0, 0), (-1, 0), bold_font),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e9edf4")),
|
||||
("GRID", (0, 0), (-1, -1), .35, colors.HexColor("#aeb7c5")),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
]))
|
||||
story.extend((table, Spacer(1, 3 * mm)))
|
||||
story.extend((
|
||||
Paragraph(f"Итого по компании: {_duration(company_group['minutes'])}, {company_group['amount']} ₽", total),
|
||||
Spacer(1, 6 * mm),
|
||||
))
|
||||
story.append(Paragraph(f"Общий итог: {_duration(report['total_minutes'])}, {report['total_amount']} ₽", grand_total))
|
||||
document.build(story)
|
||||
return output.getvalue()
|
||||
|
||||
@@ -61,3 +61,17 @@ class MonthlyReportTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "application/pdf")
|
||||
self.assertTrue(response.content.startswith(b"%PDF"))
|
||||
|
||||
def test_user_pdf_is_generated_for_all_companies(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("user-report-pdf"), {"year": 2026, "month": 7})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "application/pdf")
|
||||
self.assertEqual(response["Content-Disposition"], 'attachment; filename="report-worker-2026-07.pdf"')
|
||||
self.assertTrue(response.content.startswith(b"%PDF"))
|
||||
|
||||
def test_worker_cannot_export_another_users_general_report(self):
|
||||
other = User.objects.create_user(username="other", password="password123", must_change_password=False)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("user-report-pdf"), {"user": other.pk, "year": 2026, "month": 7})
|
||||
self.assertEqual(response["Content-Disposition"], 'attachment; filename="report-worker-2026-07.pdf"')
|
||||
|
||||
@@ -4,5 +4,6 @@ from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.monthly_report, name="monthly-report"),
|
||||
path("pdf/", views.user_pdf, name="user-report-pdf"),
|
||||
path("company/<int:company_id>/pdf/", views.company_pdf, name="company-report-pdf"),
|
||||
]
|
||||
|
||||
+13
-1
@@ -9,7 +9,7 @@ from apps.accounts.models import User
|
||||
from apps.companies.models import Company
|
||||
|
||||
from .services import build_monthly_report
|
||||
from .pdf import render_company_pdf
|
||||
from .pdf import render_company_pdf, render_user_pdf
|
||||
|
||||
|
||||
def selected_user(request):
|
||||
@@ -50,3 +50,15 @@ def company_pdf(request, company_id):
|
||||
response = HttpResponse(pdf, content_type="application/pdf")
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def user_pdf(request):
|
||||
user = selected_user(request)
|
||||
year, month = selected_period(request)
|
||||
report = build_monthly_report(user, year, month)
|
||||
pdf = render_user_pdf(report)
|
||||
filename = f"report-{user.username}-{year}-{month:02d}.pdf"
|
||||
response = HttpResponse(pdf, content_type="application/pdf")
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
@@ -37,6 +37,35 @@ class TaskAccessTests(TestCase):
|
||||
response = self.client.get(reverse("task-edit", args=(self.task.pk,)))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_worker_can_delete_own_task(self):
|
||||
own_task = Task.objects.create(
|
||||
project=self.project, assignee=self.worker, title="Своя задача", description="Описание",
|
||||
work_date=date(2026, 7, 14), duration_minutes=60, hourly_rate=Decimal("1000.00"),
|
||||
amount=Decimal("1000.00"), created_by=self.worker,
|
||||
)
|
||||
self.client.force_login(self.worker)
|
||||
response = self.client.post(reverse("task-delete", args=(own_task.pk,)))
|
||||
self.assertRedirects(response, reverse("task-list"))
|
||||
self.assertFalse(Task.objects.filter(pk=own_task.pk).exists())
|
||||
|
||||
def test_worker_cannot_delete_another_users_task(self):
|
||||
self.client.force_login(self.worker)
|
||||
response = self.client.post(reverse("task-delete", args=(self.task.pk,)))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertTrue(Task.objects.filter(pk=self.task.pk).exists())
|
||||
|
||||
def test_admin_can_delete_any_task(self):
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(reverse("task-delete", args=(self.task.pk,)))
|
||||
self.assertRedirects(response, reverse("task-list"))
|
||||
self.assertFalse(Task.objects.filter(pk=self.task.pk).exists())
|
||||
|
||||
def test_delete_confirmation_does_not_delete_task(self):
|
||||
self.client.force_login(self.other)
|
||||
response = self.client.get(reverse("task-delete", args=(self.task.pk,)))
|
||||
self.assertContains(response, "Удаление задачи")
|
||||
self.assertTrue(Task.objects.filter(pk=self.task.pk).exists())
|
||||
|
||||
def test_create_copies_rate_and_calculates_rounded_amount(self):
|
||||
self.client.force_login(self.worker)
|
||||
response = self.client.post(reverse("task-create"), {
|
||||
|
||||
@@ -6,4 +6,5 @@ urlpatterns = [
|
||||
path("", views.task_list, name="task-list"),
|
||||
path("create/", views.task_create, name="task-create"),
|
||||
path("<int:pk>/edit/", views.task_edit, name="task-edit"),
|
||||
path("<int:pk>/delete/", views.task_delete, name="task-delete"),
|
||||
]
|
||||
|
||||
@@ -59,3 +59,15 @@ def task_edit(request, pk):
|
||||
task.save()
|
||||
return redirect("task-list")
|
||||
return render(request, "tasks/task_form.html", {"form": form, "heading": "Редактирование задачи", "task": task})
|
||||
|
||||
|
||||
@login_required
|
||||
@transaction.atomic
|
||||
def task_delete(request, pk):
|
||||
task = get_object_or_404(Task.objects.select_related("project__company", "assignee"), pk=pk)
|
||||
if not request.user.is_app_admin and task.assignee_id != request.user.id:
|
||||
raise PermissionDenied
|
||||
if request.method == "POST":
|
||||
task.delete()
|
||||
return redirect("task-list")
|
||||
return render(request, "tasks/task_confirm_delete.html", {"task": task})
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
DEBUG: "1"
|
||||
SECRET_KEY: "change-me"
|
||||
ALLOWED_HOSTS: "localhost,127.0.0.1"
|
||||
CSRF_TRUSTED_ORIGINS: ""
|
||||
DATABASE_NAME: "/app/data/timerr.sqlite3"
|
||||
volumes:
|
||||
- timerr-data:/app/data
|
||||
|
||||
@@ -28,6 +28,15 @@ DEBUG = os.getenv("DEBUG", "1") == "1"
|
||||
|
||||
ALLOWED_HOSTS = [host for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if host]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
origin
|
||||
for origin in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",")
|
||||
if origin
|
||||
]
|
||||
|
||||
# Traefik terminates TLS and forwards the original protocol in this header.
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
:root{font-family:Inter,Segoe UI,Arial,sans-serif;color:#192033;background:#f4f6fa}*{box-sizing:border-box}body{margin:0}header{height:64px;background:#172033;color:white;display:flex;align-items:center;padding:0 max(24px,calc((100% - 1200px)/2));gap:36px}header a{color:white;text-decoration:none}.brand{font-size:22px;font-weight:800}nav{display:flex;align-items:center;gap:20px;margin-left:auto}nav form{margin:0}.link{background:none;border:0;color:white;padding:0}main{max-width:1200px;margin:32px auto;padding:0 24px}h1{margin-top:0}.card{background:white;border-radius:10px;padding:28px;box-shadow:0 3px 16px #18243b12}.narrow{max-width:480px;margin:60px auto}.toolbar,.filters{display:flex;align-items:center;gap:16px;justify-content:space-between}.filters{justify-content:flex-start;background:white;padding:16px;border-radius:8px;margin-bottom:22px;flex-wrap:wrap}label{font-weight:600}input,select,textarea{display:block;width:100%;padding:9px;border:1px solid #bac2d0;border-radius:6px;margin-top:5px}button,.button{border:0;border-radius:6px;background:#2f67dc;color:white;padding:10px 16px;text-decoration:none;cursor:pointer;font-weight:600}.secondary{background:#5a6477}table{width:100%;border-collapse:collapse;background:white}th,td{padding:11px;border-bottom:1px solid #dde2ea;text-align:left;vertical-align:top}th{background:#e9edf4}.table-wrap{overflow:auto}small{color:#647086}.total,.company-total,.grand-total{font-weight:700}.grand-total{font-size:20px;text-align:right}.report-company{margin:24px 0 36px}.errorlist{color:#b42318}.duration-fields{display:flex;gap:16px;border:0;padding:0;margin:20px 0}.duration-fields legend{font-weight:700;margin-bottom:8px;width:100%}.duration-fields label{width:150px}.duration-fields input{min-width:0}
|
||||
:root{font-family:Inter,Segoe UI,Arial,sans-serif;color:#192033;background:#f4f6fa}*{box-sizing:border-box}body{margin:0}header{height:64px;background:#172033;color:white;display:flex;align-items:center;padding:0 max(24px,calc((100% - 1200px)/2));gap:36px}header a{color:white;text-decoration:none}.brand{font-size:22px;font-weight:800}nav{display:flex;align-items:center;gap:20px;margin-left:auto}nav form{margin:0}.link{background:none;border:0;color:white;padding:0}main{max-width:1200px;margin:32px auto;padding:0 24px}h1{margin-top:0}.card{background:white;border-radius:10px;padding:28px;box-shadow:0 3px 16px #18243b12}.narrow{max-width:480px;margin:60px auto}.toolbar,.filters,.form-actions{display:flex;align-items:center;gap:16px;justify-content:space-between}.filters{justify-content:flex-start;background:white;padding:16px;border-radius:8px;margin-bottom:22px;flex-wrap:wrap}label{font-weight:600}input,select,textarea{display:block;width:100%;padding:9px;border:1px solid #bac2d0;border-radius:6px;margin-top:5px}button,.button{border:0;border-radius:6px;background:#2f67dc;color:white;padding:10px 16px;text-decoration:none;cursor:pointer;font-weight:600}.secondary{background:#5a6477}.danger{background:#b42318}.warning{color:#8f1d14}table{width:100%;border-collapse:collapse;background:white}th,td{padding:11px;border-bottom:1px solid #dde2ea;text-align:left;vertical-align:top}th{background:#e9edf4}.table-wrap{overflow:auto}small{color:#647086}.total,.company-total,.grand-total{font-weight:700}.grand-total{font-size:20px;text-align:right}.report-company{margin:24px 0 36px}.errorlist{color:#b42318}.duration-fields{display:flex;gap:16px;border:0;padding:0;margin:20px 0}.duration-fields legend{font-weight:700;margin-bottom:8px;width:100%}.duration-fields label{width:150px}.duration-fields input{min-width:0}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends 'base.html' %}{% load report_tags %}{% block content %}<h1>Месячный отчёт</h1>
|
||||
<form method="get" class="filters">{% if user.is_app_admin %}<label>Исполнитель <select name="user">{% for item in users %}<option value="{{ item.pk }}" {% if report.user.pk == item.pk %}selected{% endif %}>{{ item.get_full_name|default:item.username }}</option>{% endfor %}</select></label>{% endif %}<label>Месяц <input name="month" type="number" min="1" max="12" value="{{ report.month }}"></label><label>Год <input name="year" type="number" value="{{ report.year }}"></label><button>Показать</button></form>
|
||||
<h2>{{ report.user.get_full_name|default:report.user.username }}</h2>
|
||||
<div class="toolbar"><h2>{{ report.user.get_full_name|default:report.user.username }}</h2><a class="button secondary" href="{% url 'user-report-pdf' %}?user={{ report.user.pk }}&year={{ report.year }}&month={{ report.month }}">PDF общего отчёта</a></div>
|
||||
{% for group in report.companies %}<section class="report-company"><div class="toolbar"><h3>{{ group.company.name }}</h3>{% if user.is_app_admin %}<a class="button secondary" href="{% url 'company-report-pdf' group.company.pk %}?user={{ report.user.pk }}&year={{ report.year }}&month={{ report.month }}">PDF для клиента</a>{% endif %}</div>
|
||||
{% for project in group.projects.values %}<h4>{{ project.project.name }}</h4><table><thead><tr><th>Дата</th><th>Задача</th><th>Описание</th><th>Время</th><th>Ставка</th><th>Сумма</th></tr></thead><tbody>{% for task in project.tasks %}<tr><td>{{ task.work_date|date:'d.m.Y' }}</td><td>{{ task.title }}</td><td>{{ task.description }}</td><td>{{ task.duration_minutes|duration }}</td><td>{{ task.hourly_rate }} ₽</td><td>{{ task.amount }} ₽</td></tr>{% endfor %}<tr class="total"><td colspan="3">Итого по проекту</td><td>{{ project.minutes|duration }}</td><td></td><td>{{ project.amount }} ₽</td></tr></tbody></table>{% endfor %}
|
||||
<p class="company-total">Итого по компании: {{ group.minutes|duration }}, {{ group.amount }} ₽</p></section>{% empty %}<p>За выбранный месяц выполненных задач нет.</p>{% endfor %}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends 'base.html' %}{% block content %}<section class="card narrow"><h1>Удаление задачи</h1>
|
||||
<p>Вы действительно хотите удалить задачу <strong>«{{ task.title }}»</strong> от {{ task.work_date|date:'d.m.Y' }}?</p>
|
||||
<p class="warning">Задача будет удалена безвозвратно и перестанет отображаться в отчётах.</p>
|
||||
<form method="post">{% csrf_token %}<div class="form-actions"><a href="{% url 'task-edit' task.pk %}">Отмена</a><button class="danger">Удалить</button></div></form>
|
||||
</section>{% endblock %}
|
||||
@@ -10,4 +10,4 @@
|
||||
<label>Минуты{{ form.duration_minutes_input }}{{ form.duration_minutes_input.errors }}</label>
|
||||
</fieldset>
|
||||
<p>{{ form.status.label_tag }}{{ form.status }}{{ form.status.errors }}</p>
|
||||
<button>Сохранить</button> <a href="{% url 'task-list' %}">Отмена</a></form></section>{% endblock %}
|
||||
<div class="form-actions"><div><button>Сохранить</button> <a href="{% url 'task-list' %}">Отмена</a></div>{% if task %}<a class="button danger" href="{% url 'task-delete' task.pk %}">Удалить задачу</a>{% endif %}</div></form></section>{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user