Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
@@ -0,0 +1,201 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold">Create Branch</h1>
<p class="mt-1 text-sm text-slate-500">
System Admin can create branches for any audit firm. Firm Admin can create branches only for their own audit firm.
</p>
</div>
{% if flash %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{{ flash }}
</div>
{% endif %}
<form class="rounded-2xl border bg-white p-5 shadow-soft max-w-5xl" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-5">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Firm</span>
{% if can_change_tenant %}
<select class="border rounded-xl px-3 py-2" name="tenant_id" required>
<option value="">Select Firm</option>
{% for t in tenants %}
<option value="{{ t.id }}" {% if selected_tenant_id == t.id %}selected{% endif %}>
{{ t.name }} ({{ t.code }})
</option>
{% endfor %}
</select>
{% else %}
<input type="hidden" name="tenant_id" value="{{ selected_tenant_id }}" />
<div class="rounded-xl border bg-slate-50 px-3 py-2 text-sm text-slate-700">
{% for t in tenants %}
{% if t.id == selected_tenant_id %}
{{ t.name }} ({{ t.code }})
{% endif %}
{% endfor %}
</div>
{% endif %}
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch Code</span>
<input class="border rounded-xl px-3 py-2" name="code" required />
</label>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch Name</span>
<input class="border rounded-xl px-3 py-2" name="name" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Timezone</span>
<input class="border rounded-xl px-3 py-2" name="timezone" value="Asia/Kolkata" required />
</label>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Office Start Time (HH:MM)</span>
<input class="border rounded-xl px-3 py-2" name="office_start_time" placeholder="09:30" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Office End Time (HH:MM)</span>
<input class="border rounded-xl px-3 py-2" name="office_end_time" placeholder="18:30" />
</label>
</div>
<div class="border-t pt-4">
<div class="font-semibold">SMTP Credentials</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Host</span>
<input class="border rounded-xl px-3 py-2" name="smtp_host" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Port</span>
<input class="border rounded-xl px-3 py-2" name="smtp_port" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Username</span>
<input class="border rounded-xl px-3 py-2" name="smtp_username" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Password</span>
<input class="border rounded-xl px-3 py-2" name="smtp_password" />
</label>
<label class="mt-2 flex items-center gap-2">
<input type="checkbox" name="smtp_use_tls" checked />
<span class="text-sm text-slate-700">Use TLS</span>
</label>
</div>
</div>
<div class="border-t pt-4">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Local Storage Path</span>
<input class="border rounded-xl px-3 py-2" name="local_storage_path" placeholder="D:\AuditFirm\Firm\Branch" />
</label>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Branch Identity (Compliance)</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">GSTIN</span>
<input class="border rounded-xl px-3 py-2" name="gstin" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">PAN</span>
<input class="border rounded-xl px-3 py-2" name="pan" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Address Line 1</span>
<input class="border rounded-xl px-3 py-2" name="address_line1" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Address Line 2</span>
<input class="border rounded-xl px-3 py-2" name="address_line2" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">City</span>
<input class="border rounded-xl px-3 py-2" name="city" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">State</span>
<input class="border rounded-xl px-3 py-2" name="state" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">PIN Code</span>
<input class="border rounded-xl px-3 py-2" name="pin_code" />
</label>
</div>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Attendance Geo/IP Controls</div>
<p class="mt-1 text-xs text-slate-500">Enable geo-fenced attendance for this branch. Default radius is 100 meters. IP validation is optional and useful only for office LAN/static public IP.</p>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Geo Address / Landmark</span>
<input class="border rounded-xl px-3 py-2" name="geo_address" placeholder="Office location / landmark" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Latitude</span>
<input class="border rounded-xl px-3 py-2" name="latitude" placeholder="13.082680" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Longitude</span>
<input class="border rounded-xl px-3 py-2" name="longitude" placeholder="80.270718" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Allowed Radius (meters)</span>
<input class="border rounded-xl px-3 py-2" name="attendance_geo_radius_meters" value="100" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Grace Minutes</span>
<input class="border rounded-xl px-3 py-2" name="attendance_grace_minutes" value="10" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Half Day After (HH:MM)</span>
<input class="border rounded-xl px-3 py-2" name="attendance_half_day_after_time" placeholder="13:00" />
</label>
<label class="mt-6 flex items-center gap-2">
<input type="checkbox" name="attendance_rule_enabled" checked />
<span class="text-sm text-slate-700">Enable timezone/late/half-day rules</span>
</label>
<label class="mt-6 flex items-center gap-2">
<input type="checkbox" name="attendance_geo_enabled" />
<span class="text-sm text-slate-700">Enable geo-fenced attendance</span>
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Allowed Office IP / CIDR List</span>
<textarea class="border rounded-xl px-3 py-2" name="attendance_allowed_ip_csv" rows="2" placeholder="Example: 103.x.x.x, 192.168.1.0/24"></textarea>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="attendance_ip_enabled" />
<span class="text-sm text-slate-700">Enable optional IP/LAN validation</span>
</label>
</div>
</div>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white" type="submit">Create Branch</button>
<a class="rounded-xl border px-4 py-2 text-sm" href="/system-settings/branches">Cancel</a>
</div>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,184 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold">Edit Branch</h1>
<p class="mt-1 text-sm text-slate-500">
Branch remains mapped to its audit firm. Firm Admin can edit only branches of their own audit firm.
</p>
</div>
{% if flash %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{{ flash }}
</div>
{% endif %}
<form class="rounded-2xl border bg-white p-5 shadow-soft max-w-5xl" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-5">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Firm</span>
<div class="rounded-xl border bg-slate-50 px-3 py-2 text-sm text-slate-700">
{{ branch.tenant.name if branch.tenant else ('Audit Firm ' ~ branch.tenant_id) }}
</div>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch Code</span>
<input class="border rounded-xl bg-slate-50 px-3 py-2" value="{{ branch.code }}" readonly />
</label>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch Name</span>
<input class="border rounded-xl px-3 py-2" name="name" value="{{ branch.name or '' }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Timezone</span>
<input class="border rounded-xl px-3 py-2" name="timezone" value="{{ branch.timezone or 'Asia/Kolkata' }}" required />
</label>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Office Start Time (HH:MM)</span>
<input class="border rounded-xl px-3 py-2" name="office_start_time" value="{{ branch.office_start_time.strftime('%H:%M') if branch.office_start_time else '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Office End Time (HH:MM)</span>
<input class="border rounded-xl px-3 py-2" name="office_end_time" value="{{ branch.office_end_time.strftime('%H:%M') if branch.office_end_time else '' }}" />
</label>
</div>
<div class="border-t pt-4">
<div class="font-semibold">SMTP Credentials</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Host</span>
<input class="border rounded-xl px-3 py-2" name="smtp_host" value="{{ branch.smtp_host or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Port</span>
<input class="border rounded-xl px-3 py-2" name="smtp_port" value="{{ branch.smtp_port or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Username</span>
<input class="border rounded-xl px-3 py-2" name="smtp_username" value="{{ branch.smtp_username or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Password</span>
<input class="border rounded-xl px-3 py-2" name="smtp_password" value="{{ branch.smtp_password or '' }}" />
</label>
<label class="mt-2 flex items-center gap-2">
<input type="checkbox" name="smtp_use_tls" {% if branch.smtp_use_tls %}checked{% endif %} />
<span class="text-sm text-slate-700">Use TLS</span>
</label>
</div>
</div>
<div class="border-t pt-4">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Local Storage Path</span>
<input class="border rounded-xl px-3 py-2" name="local_storage_path" value="{{ branch.local_storage_path or '' }}" />
</label>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Branch Identity (Compliance)</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">GSTIN</span>
<input class="border rounded-xl px-3 py-2" name="gstin" value="{{ settings.gstin or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">PAN</span>
<input class="border rounded-xl px-3 py-2" name="pan" value="{{ settings.pan or '' }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Address Line 1</span>
<input class="border rounded-xl px-3 py-2" name="address_line1" value="{{ settings.address_line1 or '' }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Address Line 2</span>
<input class="border rounded-xl px-3 py-2" name="address_line2" value="{{ settings.address_line2 or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">City</span>
<input class="border rounded-xl px-3 py-2" name="city" value="{{ settings.city or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">State</span>
<input class="border rounded-xl px-3 py-2" name="state" value="{{ settings.state or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">PIN Code</span>
<input class="border rounded-xl px-3 py-2" name="pin_code" value="{{ settings.pin_code or '' }}" />
</label>
</div>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Attendance Geo/IP Controls</div>
<p class="mt-1 text-xs text-slate-500">If geo/IP validation fails, employee punch is still saved but marked pending approval for OD/client visit review.</p>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Geo Address / Landmark</span>
<input class="border rounded-xl px-3 py-2" name="geo_address" value="{{ settings.geo_address or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Latitude</span>
<input class="border rounded-xl px-3 py-2" name="latitude" value="{{ settings.latitude if settings.latitude is not none else '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Longitude</span>
<input class="border rounded-xl px-3 py-2" name="longitude" value="{{ settings.longitude if settings.longitude is not none else '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Allowed Radius (meters)</span>
<input class="border rounded-xl px-3 py-2" name="attendance_geo_radius_meters" value="{{ settings.attendance_geo_radius_meters or 100 }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Grace Minutes</span>
<input class="border rounded-xl px-3 py-2" name="attendance_grace_minutes" value="{{ settings.attendance_grace_minutes if settings.attendance_grace_minutes is not none else 10 }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Half Day After (HH:MM)</span>
<input class="border rounded-xl px-3 py-2" name="attendance_half_day_after_time" value="{{ settings.attendance_half_day_after_time.strftime('%H:%M') if settings.attendance_half_day_after_time else '' }}" placeholder="13:00" />
</label>
<label class="mt-6 flex items-center gap-2">
<input type="checkbox" name="attendance_rule_enabled" {% if settings.attendance_rule_enabled is not defined or settings.attendance_rule_enabled %}checked{% endif %} />
<span class="text-sm text-slate-700">Enable timezone/late/half-day rules</span>
</label>
<label class="mt-6 flex items-center gap-2">
<input type="checkbox" name="attendance_geo_enabled" {% if settings.attendance_geo_enabled %}checked{% endif %} />
<span class="text-sm text-slate-700">Enable geo-fenced attendance</span>
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Allowed Office IP / CIDR List</span>
<textarea class="border rounded-xl px-3 py-2" name="attendance_allowed_ip_csv" rows="2">{{ settings.attendance_allowed_ip_csv or '' }}</textarea>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="attendance_ip_enabled" {% if settings.attendance_ip_enabled %}checked{% endif %} />
<span class="text-sm text-slate-700">Enable optional IP/LAN validation</span>
</label>
</div>
</div>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white" type="submit">Save Branch</button>
<a class="rounded-xl border px-4 py-2 text-sm" href="/system-settings/branches">Cancel</a>
</div>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,63 @@
{% extends "ui/templates/base/layout.html" %}
{% import "ui/templates/components/macros.html" as ui %}
{% block content %}
<div class="space-y-6">
{% set perms = current_user_permissions or [] %}
{% set roles = current_user_roles or [] %}
{% set add_branch_cta = '<a href="/system-settings/branches/new" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Add Branch</a>' if can_manage_branches(current_user, perms, roles) else '' %}
{{ ui.page_shell('Branches', 'Branch directory with search, paging, and quick navigation to branch settings.', add_branch_cta) }}
<div class="overflow-hidden rounded-3xl bg-white shadow-soft">
{{ ui.search_bar('/system-settings/branches', filters.q, filters.per_page) }}
{% if branches %}
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="p-3 text-left">Branch</th>
<th class="p-3 text-left">Audit Firm</th>
<th class="p-3 text-left">Timezone</th>
<th class="p-3 text-left">Flags</th>
<th class="p-3 text-left">Actions</th>
</tr>
</thead>
<tbody>
{% for b in branches %}
<tr class="border-t align-top">
<td class="p-3">
<div class="font-medium text-slate-900">{{ b.name }}</div>
<div class="text-xs text-slate-500">{{ b.code }}</div>
</td>
<td class="p-3">
{{ tenants[b.tenant_id].name if b.tenant_id in tenants else '-' }}
</td>
<td class="p-3">{{ b.timezone }}</td>
<td class="p-3">
<div class="flex flex-wrap gap-2">
{{ ui.badge('Active','emerald') if b.is_active else ui.badge('Inactive','rose') }}
{{ ui.badge('Login enabled','sky') if b.allow_login else ui.badge('Login disabled','amber') }}
{% if b.is_head_office %}{{ ui.badge('Head Office','brand') }}{% endif %}
</div>
</td>
<td class="p-3">
{% if can_manage_branches(current_user, perms, roles) %}
<a class="text-slate-900 underline" href="/system-settings/branches/{{ b.id }}/edit">Edit</a>
{% else %}
<span class="text-slate-400">View only</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ ui.pagination(branches_page, '/system-settings/branches', request.url.query) }}
{% else %}
<div class="p-6">
{{ ui.empty_state('No branches found for the current scope.') }}
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,167 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="af-page-shell space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="af-page-title">Firm Branding Settings</h1>
<p class="af-page-subtitle">Configure firm logo, favicon, standard colours, contact details and invoice presentation defaults.</p>
</div>
{% if saved %}
<div class="af-badge af-badge-success">Saved successfully</div>
{% endif %}
</div>
{% if can_change_tenant %}
<div class="af-card p-4">
<form method="get" action="/system-settings/branding" class="grid gap-3 md:grid-cols-3 md:items-end">
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Audit Firm</span>
<select name="tenant_id" class="af-select" onchange="this.form.submit()">
{% for t in tenants %}
<option value="{{ t.id }}" {% if tenant and t.id == tenant.id %}selected{% endif %}>{{ t.display_name or t.name }} ({{ t.code }})</option>
{% endfor %}
</select>
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Branch</span>
<select name="branch_id" class="af-select" onchange="this.form.submit()">
{% for b in branches %}
<option value="{{ b.id }}" {% if branch and b.id == branch.id %}selected{% endif %}>{{ b.name }} ({{ b.code }})</option>
{% endfor %}
</select>
</label>
<div class="text-sm text-slate-500">System Admin can preview/edit branding for any audit firm. Firm Admin is restricted to own firm.</div>
</form>
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="tenant_id" value="{{ tenant.id }}" />
{% if branch %}<input type="hidden" name="branch_id" value="{{ branch.id }}" />{% endif %}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div class="space-y-6">
<section class="af-card p-5">
<div class="af-section-heading">Brand Identity</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="grid gap-1 md:col-span-2">
<span class="text-sm font-medium text-slate-600">Display Name</span>
<input class="af-input" name="display_name" value="{{ tenant.display_name or tenant.name }}" placeholder="A R R R & ASSOCIATES" />
<span class="text-xs text-slate-500">This name will be used in the sidebar, title bar and future invoice/report headers.</span>
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Primary Colour</span>
<input class="af-input h-11" type="color" name="primary_color" value="{{ tenant.primary_color or '#2563eb' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Accent / Dark Colour</span>
<input class="af-input h-11" type="color" name="accent_color" value="{{ tenant.accent_color or '#0f172a' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Firm Logo</span>
<input class="af-input" type="file" name="logo_file" accept=".png,.jpg,.jpeg,.webp,.svg" />
<span class="text-xs text-slate-500">Recommended: transparent PNG/SVG, square or horizontal.</span>
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Favicon</span>
<input class="af-input" type="file" name="favicon_file" accept=".ico,.png,.svg" />
<span class="text-xs text-slate-500">Recommended: ICO/PNG 32x32 or 64x64.</span>
</label>
</div>
</section>
<section class="af-card p-5">
<div class="af-section-heading">Contact & Statutory Details</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Website</span>
<input class="af-input" name="website_url" value="{{ tenant.website_url or '' }}" placeholder="https://arrr.associates" />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Contact Email</span>
<input class="af-input" name="contact_email" value="{{ tenant.contact_email or '' }}" placeholder="office@example.com" />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Contact Mobile</span>
<input class="af-input" name="contact_mobile" value="{{ tenant.contact_mobile or '' }}" placeholder="+91 XXXXX XXXXX" />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">GSTIN</span>
<input class="af-input" name="gstin" value="{{ settings.gstin if settings else '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">PAN</span>
<input class="af-input" name="pan" value="{{ settings.pan if settings else '' }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm font-medium text-slate-600">Address Line 1</span>
<input class="af-input" name="address_line1" value="{{ settings.address_line1 if settings else '' }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm font-medium text-slate-600">Address Line 2</span>
<input class="af-input" name="address_line2" value="{{ settings.address_line2 if settings else '' }}" />
</label>
<label class="grid gap-1"><span class="text-sm font-medium text-slate-600">City</span><input class="af-input" name="city" value="{{ settings.city if settings else '' }}" /></label>
<label class="grid gap-1"><span class="text-sm font-medium text-slate-600">State</span><input class="af-input" name="state" value="{{ settings.state if settings else '' }}" /></label>
<label class="grid gap-1"><span class="text-sm font-medium text-slate-600">PIN Code</span><input class="af-input" name="pin_code" value="{{ settings.pin_code if settings else '' }}" /></label>
</div>
</section>
<section class="af-card p-5">
<div class="af-section-heading">Invoice / Payment Defaults</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="grid gap-1"><span class="text-sm font-medium text-slate-600">Bank Name</span><input class="af-input" name="bank_name" value="{{ settings.bank_name if settings else '' }}" /></label>
<label class="grid gap-1"><span class="text-sm font-medium text-slate-600">Account Name</span><input class="af-input" name="bank_account_name" value="{{ settings.bank_account_name if settings else '' }}" /></label>
<label class="grid gap-1"><span class="text-sm font-medium text-slate-600">Account Number</span><input class="af-input" name="bank_account_number" value="{{ settings.bank_account_number if settings else '' }}" /></label>
<label class="grid gap-1"><span class="text-sm font-medium text-slate-600">IFSC</span><input class="af-input" name="bank_ifsc" value="{{ settings.bank_ifsc if settings else '' }}" /></label>
<label class="grid gap-1 md:col-span-2"><span class="text-sm font-medium text-slate-600">UPI ID</span><input class="af-input" name="upi_id" value="{{ settings.upi_id if settings else '' }}" /></label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm font-medium text-slate-600">Invoice Footer Text</span>
<textarea class="af-textarea min-h-24" name="invoice_footer_text">{{ settings.invoice_footer_text if settings else '' }}</textarea>
</label>
</div>
</section>
<div class="flex flex-wrap gap-3">
<button class="af-btn af-btn-primary" type="submit">Save Branding</button>
<a class="af-btn af-btn-secondary" href="/system-settings">Back to Setup</a>
</div>
</div>
<aside class="space-y-4">
<div class="af-card overflow-hidden">
<div class="p-5" style="background: {{ tenant.primary_color or '#2563eb' }}; color: white;">
<div class="flex items-center gap-3">
{% if tenant.logo_path %}
<img src="{{ tenant.logo_path }}" alt="Firm logo" class="h-12 w-12 rounded-2xl bg-white object-contain p-1" />
{% else %}
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/20 text-lg font-bold">AF</div>
{% endif %}
<div class="min-w-0">
<div class="truncate text-base font-semibold">{{ tenant.display_name or tenant.name }}</div>
<div class="truncate text-xs opacity-90">{{ branch.name if branch else 'Default Branch' }}</div>
</div>
</div>
</div>
<div class="space-y-3 p-5 text-sm text-slate-600">
<div><span class="font-medium text-slate-800">Email:</span> {{ tenant.contact_email or '-' }}</div>
<div><span class="font-medium text-slate-800">Mobile:</span> {{ tenant.contact_mobile or '-' }}</div>
<div><span class="font-medium text-slate-800">GSTIN:</span> {{ settings.gstin if settings and settings.gstin else '-' }}</div>
<div><span class="font-medium text-slate-800">PAN:</span> {{ settings.pan if settings and settings.pan else '-' }}</div>
</div>
</div>
<div class="af-card-muted p-5 text-sm text-slate-600">
<div class="font-semibold text-slate-900">Where this is used</div>
<p class="mt-2">This phase immediately applies firm logo, favicon and theme colours to the common layout. Invoice and client portal usage will be refined in later phases.</p>
</div>
</aside>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,108 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% set ui_perms = current_user_permissions or [] %}
{% set ui_roles = current_user_roles or [] %}
<h1 class="text-2xl font-semibold">Workspace</h1>
<p class="text-slate-600 mt-1">
Welcome{% if current_user and current_user.full_name %}, {{ current_user.full_name }}{% endif %}.
Use the options available for your access level.
</p>
{% set show_admin_cards =
can_view_tenants(current_user, ui_perms, ui_roles)
or can_view_branches(current_user, ui_perms, ui_roles)
or can_view_users(current_user, ui_perms, ui_roles)
or can_view_audit(current_user, ui_perms, ui_roles)
or can_view_rbac(current_user, ui_perms, ui_roles)
or ("Firm Admin" in ui_roles)
%}
{% if show_admin_cards %}
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{% if can_view_tenants(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/tenants" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Audit Firms</div>
<div class="text-sm text-slate-600 mt-1">Create and manage audit firms.</div>
</a>
{% endif %}
{% if can_view_branches(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/branches" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Branches</div>
<div class="text-sm text-slate-600 mt-1">Branch settings, SMTP, storage, identity and policy controls.</div>
</a>
{% endif %}
{% if can_view_settings(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/financial-years" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Financial Years</div>
<div class="text-sm text-slate-600 mt-1">Create, switch, lock, backup and manage financial year context.</div>
</a>
{% endif %}
{% if "Firm Admin" in ui_roles or "System Admin" in ui_roles %}
<a href="/system-settings/branding" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Firm Branding</div>
<div class="text-sm text-slate-600 mt-1">Logo, favicon, standard colours, contact details and invoice defaults.</div>
</a>
{% endif %}
{% if "Firm Admin" in ui_roles or "System Admin" in ui_roles %}
<a href="/email/settings" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Firm Email / SMTP</div>
<div class="text-sm text-slate-600 mt-1">Configure SMTP, IMAP, sender identity and authentication email delivery.</div>
</a>
<a href="/email/templates" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Email Templates</div>
<div class="text-sm text-slate-600 mt-1">Edit OTP, password reset, invite and notification email templates.</div>
</a>
{% endif %}
{% if can_view_users(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/users" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Users</div>
<div class="text-sm text-slate-600 mt-1">Manage user accounts, access and lifecycle actions.</div>
</a>
{% endif %}
{% if can_view_rbac(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/rbac/roles" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">RBAC</div>
<div class="text-sm text-slate-600 mt-1">Configure roles and permissions.</div>
</a>
{% endif %}
{% if can_view_audit(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/audit-logs" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Audit Logs</div>
<div class="text-sm text-slate-600 mt-1">Review recorded actions for your allowed scope.</div>
</a>
{% endif %}
</div>
{% else %}
<div class="mt-6 rounded-2xl border bg-white p-6 shadow-soft">
<div class="text-lg font-semibold text-slate-900">Your workspace is ready</div>
<p class="mt-2 text-sm text-slate-600">
Admin modules are not assigned to your current role. Profile, task, and other user-facing features can be added here next.
</p>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="font-medium text-slate-800">Profile / Account</div>
<div class="mt-1 text-sm text-slate-600">
Use this area later for profile, password change, notifications, and personal settings.
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="font-medium text-slate-800">Upcoming Features</div>
<div class="mt-1 text-sm text-slate-600">
Staff dashboard, tasks, attendance, documents, and other user workspace modules can be plugged in here.
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,59 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold">Year Backup Export</h1>
<p class="mt-1 text-sm text-slate-600">Financial Year {{ fy.year_code }} / Assessment Year {{ fy.assessment_year }}</p>
</div>
<a href="/system-settings/financial-years?tenant_id={{ fy.tenant_id }}" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Financial Years</a>
</div>
{% if request.query_params.get('exported') %}
<div class="mt-4 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-800">Backup export generated successfully.</div>
{% endif %}
{% if request.query_params.get('error') %}
<div class="mt-4 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Backup file is not available on disk.</div>
{% endif %}
<section class="mt-5 rounded-2xl border bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">Create export ZIP</h2>
<p class="mt-1 max-w-3xl text-sm text-slate-600">
This creates a year-wise archive containing CSV indexes for engagements, tasks, engagement documents, notice/case records, billing invoices, payments, and available stored document files.
</p>
</div>
{% if can_manage_fy %}
<form method="post" action="/system-settings/financial-years/{{ fy.id }}/backup/export">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white" type="submit">Generate Backup ZIP</button>
</form>
{% endif %}
</div>
</section>
<div class="mt-5 overflow-hidden rounded-2xl border bg-white">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="p-3 text-left">Generated At</th>
<th class="p-3 text-left">Status</th>
<th class="p-3 text-left">Size</th>
<th class="p-3 text-right">Action</th>
</tr>
</thead>
<tbody>
{% for export in exports %}
<tr class="border-t">
<td class="p-3">{{ export.generated_at_utc }}</td>
<td class="p-3"><span class="rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700">{{ export.export_status|title }}</span></td>
<td class="p-3">{{ (export.file_size_bytes / 1024)|round(1) }} KB</td>
<td class="p-3 text-right"><a href="/system-settings/financial-years/backups/{{ export.id }}/download" class="font-medium text-brand-700 hover:underline">Download</a></td>
</tr>
{% else %}
<tr><td colspan="4" class="p-6 text-center text-slate-500">No backup exports generated yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<h1 class="text-2xl font-semibold">Create Financial Year</h1>
<form class="mt-5 max-w-2xl rounded-2xl border bg-white p-5" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-4">
{% if tenants|length > 1 %}
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Audit Firm</span>
<select name="tenant_id" class="rounded-xl border px-3 py-2" required>
{% for tenant in tenants %}
<option value="{{ tenant.id }}" {% if tenant.id == selected_tenant_id %}selected{% endif %}>{{ tenant.name }} ({{ tenant.code }})</option>
{% endfor %}
</select>
</label>
{% else %}
<input type="hidden" name="tenant_id" value="{{ selected_tenant_id }}" />
{% endif %}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Financial Year Code</span>
<input class="rounded-xl border px-3 py-2" name="year_code" value="{{ form_data.year_code or '' }}" placeholder="2025-26" required />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Assessment Year</span>
<input class="rounded-xl border px-3 py-2" name="assessment_year" value="{{ form_data.assessment_year or '' }}" placeholder="2026-27" required />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Start Date</span>
<input type="date" class="rounded-xl border px-3 py-2" name="start_date" value="{{ form_data.start_date or '' }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">End Date</span>
<input type="date" class="rounded-xl border px-3 py-2" name="end_date" value="{{ form_data.end_date or '' }}" required />
</label>
</div>
<label class="flex items-center gap-2 rounded-xl border bg-slate-50 px-3 py-2">
<input type="checkbox" name="is_current" />
<span class="text-sm text-slate-700">Make this the current financial year for this audit firm</span>
</label>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white" type="submit">Save</button>
<a class="rounded-xl border px-4 py-2 text-sm" href="/system-settings/financial-years?tenant_id={{ selected_tenant_id }}">Cancel</a>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,39 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<h1 class="text-2xl font-semibold">Edit Financial Year</h1>
<p class="mt-1 text-sm text-slate-600">{{ tenant.name if tenant else '' }} • {{ fy.year_code }}</p>
<form class="mt-5 max-w-2xl rounded-2xl border bg-white p-5" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-4">
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Financial Year Code</span>
<input class="rounded-xl border bg-slate-100 px-3 py-2 text-slate-500" value="{{ fy.year_code }}" disabled />
</label>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Assessment Year</span>
<input class="rounded-xl border px-3 py-2" name="assessment_year" value="{{ fy.assessment_year }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Status</span>
<input class="rounded-xl border bg-slate-100 px-3 py-2 text-slate-500" value="{{ 'Locked' if fy.is_locked else 'Open' }}{% if fy.is_current %} / Current{% endif %}" disabled />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">Start Date</span>
<input type="date" class="rounded-xl border px-3 py-2" name="start_date" value="{{ fy.start_date }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm font-medium text-slate-600">End Date</span>
<input type="date" class="rounded-xl border px-3 py-2" name="end_date" value="{{ fy.end_date }}" required />
</label>
</div>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white" type="submit">Save</button>
<a class="rounded-xl border px-4 py-2 text-sm" href="/system-settings/financial-years?tenant_id={{ fy.tenant_id }}">Cancel</a>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,83 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold">Financial Years</h1>
<p class="mt-1 text-sm text-slate-600">Manage active financial year context, year locking and assessment year mapping.</p>
</div>
{% if can_manage_fy %}
<a href="/system-settings/financial-years/new?tenant_id={{ selected_tenant_id }}" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Add Financial Year</a>
{% endif %}
</div>
{% if tenants|length > 1 %}
<form class="mt-5 rounded-2xl border bg-white p-4" method="get">
<label class="grid max-w-md gap-1">
<span class="text-sm font-medium text-slate-600">Audit Firm</span>
<select name="tenant_id" onchange="this.form.submit()" class="rounded-xl border px-3 py-2 text-sm">
{% for tenant in tenants %}
<option value="{{ tenant.id }}" {% if tenant.id == selected_tenant_id %}selected{% endif %}>{{ tenant.name }} ({{ tenant.code }})</option>
{% endfor %}
</select>
</label>
</form>
{% endif %}
<div class="mt-5 overflow-hidden rounded-2xl border bg-white">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="p-3 text-left">Financial Year</th>
<th class="p-3 text-left">Assessment Year</th>
<th class="p-3 text-left">Period</th>
<th class="p-3 text-left">Current</th>
<th class="p-3 text-left">Locked</th>
<th class="p-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for fy in financial_years %}
<tr class="border-t">
<td class="p-3 font-medium text-slate-900">{{ fy.year_code }}</td>
<td class="p-3">{{ fy.assessment_year }}</td>
<td class="p-3">{{ fy.start_date }} to {{ fy.end_date }}</td>
<td class="p-3">
{% if fy.is_current %}<span class="rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700">Current</span>{% else %}<span class="text-slate-400">No</span>{% endif %}
</td>
<td class="p-3">
{% if fy.is_locked %}<span class="rounded-full bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">Locked</span>{% else %}<span class="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600">Open</span>{% endif %}
</td>
<td class="p-3">
<div class="flex flex-wrap justify-end gap-2">
<a href="/system-settings/context/financial-year/{{ fy.year_code }}" class="rounded-lg border px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Use</a>
<a href="/system-settings/financial-years/{{ fy.id }}/backup" class="rounded-lg border px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Backup</a>
{% if can_manage_fy and not fy.is_locked %}
<a href="/system-settings/financial-years/{{ fy.id }}/edit" class="rounded-lg border px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">Edit</a>
{% endif %}
{% if can_manage_fy and not fy.is_current %}
<form method="post" action="/system-settings/financial-years/{{ fy.id }}/make-current">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-lg border px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50" type="submit">Make Current</button>
</form>
{% endif %}
{% if can_manage_fy and not fy.is_locked %}
<form method="post" action="/system-settings/financial-years/{{ fy.id }}/lock">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-lg border border-amber-200 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-50" type="submit">Lock</button>
</form>
{% elif can_manage_fy and fy.is_locked %}
<form method="post" action="/system-settings/financial-years/{{ fy.id }}/unlock">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50" type="submit">Unlock</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="p-6 text-center text-slate-500">No financial years created yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,66 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<h1 class="text-2xl font-semibold">Create Audit Firm</h1>
<form class="mt-4 rounded-2xl bg-white border p-5 max-w-3xl" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Audit Firm Code</span>
<input class="border rounded-xl px-3 py-2" name="code" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Audit Firm Name</span>
<input class="border rounded-xl px-3 py-2" name="name" required />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Audit Firm Type</span>
<select class="border rounded-xl px-3 py-2" name="firm_type" required>
<option value="partnership" selected>Partnership Firm</option>
<option value="proprietorship">Proprietorship Firm</option>
<option value="individual">Individual Audit Practice</option>
</select>
<span class="text-xs text-slate-500">Review partner is applicable only for partnership firms and assurance engagements.</span>
</label>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Audit Firm Default Policies</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Default Timezone</span>
<input class="border rounded-xl px-3 py-2" name="default_timezone" value="Asia/Kolkata" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Default Session Duration (minutes)</span>
<input class="border rounded-xl px-3 py-2" name="default_session_duration_minutes" value="480" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Default OTP Required Roles (CSV)</span>
<input class="border rounded-xl px-3 py-2" name="default_otp_required_roles_csv" value="Partner,System Admin" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Default Storage Mode</span>
<select class="border rounded-xl px-3 py-2" name="default_storage_mode">
<option value="local_only" selected>local_only</option>
<option value="cloud_only">cloud_only</option>
<option value="hybrid">hybrid</option>
</select>
</label>
</div>
</div>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm" type="submit">Save</button>
<a class="rounded-xl border px-4 py-2 text-sm" href="/system-settings/tenants">Cancel</a>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,70 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<h1 class="text-2xl font-semibold">Edit Audit Firm</h1>
<form class="mt-4 rounded-2xl bg-white border p-5 max-w-3xl" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Audit Firm Code</span>
<input class="border rounded-xl px-3 py-2 bg-slate-50" name="code" value="{{ tenant.code }}" readonly />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Audit Firm Name</span>
<input class="border rounded-xl px-3 py-2" name="name" value="{{ tenant.name }}" required />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Audit Firm Type</span>
<select class="border rounded-xl px-3 py-2" name="firm_type" required>
<option value="partnership" {% if (tenant.firm_type or 'proprietorship') == 'partnership' %}selected{% endif %}>Partnership Firm</option>
<option value="proprietorship" {% if tenant.firm_type == 'proprietorship' %}selected{% endif %}>Proprietorship Firm</option>
<option value="individual" {% if tenant.firm_type == 'individual' %}selected{% endif %}>Individual Audit Practice</option>
</select>
<span class="text-xs text-slate-500">Review partner is applicable only for partnership firms and assurance engagements.</span>
</label>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Audit Firm Default Policies</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Default Timezone</span>
<input class="border rounded-xl px-3 py-2" name="default_timezone" value="{{ tenant.default_timezone or 'Asia/Kolkata' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Default Session Duration (minutes)</span>
<input class="border rounded-xl px-3 py-2" name="default_session_duration_minutes" value="{{ tenant.default_session_duration_minutes or 480 }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Default OTP Required Roles (CSV)</span>
<input class="border rounded-xl px-3 py-2" name="default_otp_required_roles_csv" value="{{ tenant.default_otp_required_roles_csv or 'Partner,System Admin' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Default Storage Mode</span>
<select class="border rounded-xl px-3 py-2" name="default_storage_mode">
{% for value in ['local_only','cloud_only','hybrid'] %}
<option value="{{ value }}" {% if (tenant.default_storage_mode or 'local_only') == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="is_active" value="1" {% if tenant.is_active %}checked{% endif %} /> Active
</label>
</div>
</div>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm" type="submit">Save</button>
<a class="rounded-xl border px-4 py-2 text-sm" href="/system-settings/tenants">Cancel</a>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,14 @@
{% extends "ui/templates/base/layout.html" %}
{% import "ui/templates/components/macros.html" as ui %}
{% block content %}
<div class="space-y-6">
{{ ui.page_shell('Audit Firms', 'Audit Firm master with search, paging, and quick actions for branch setup.', '<a href="/system-settings/tenants/new" class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm">Add Audit Firm</a>') }}
<div class="overflow-hidden rounded-3xl bg-white shadow-soft">
{{ ui.search_bar('/system-settings/tenants', filters.q, filters.per_page) }}
{% if tenants %}
<table class="w-full text-sm"><thead class="bg-slate-50 text-slate-600"><tr><th class="text-left p-3">ID</th><th class="text-left p-3">Code</th><th class="text-left p-3">Name</th><th class="text-left p-3">Firm Type</th><th class="text-left p-3">Status</th><th class="text-left p-3">Actions</th></tr></thead><tbody>{% for t in tenants %}<tr class="border-t"><td class="p-3">{{ t.id }}</td><td class="p-3">{{ t.code }}</td><td class="p-3">{{ t.name }}</td><td class="p-3">{{ (t.firm_type or 'proprietorship')|replace('_',' ')|title }}</td><td class="p-3">{{ ui.badge('Active','emerald') if t.is_active else ui.badge('Inactive','rose') }}</td><td class="p-3"><div class="flex gap-3"><a class="text-slate-900 underline" href="/system-settings/tenants/{{ t.id }}/edit">Edit</a><a class="text-slate-900 underline" href="/system-settings/branches/new?tenant_id={{ t.id }}">Add Branch</a></div></td></tr>{% endfor %}</tbody></table>
{{ ui.pagination(tenants_page, '/system-settings/tenants', request.url.query) }}
{% else %}<div class="p-6">{{ ui.empty_state('No audit firms found.') }}</div>{% endif %}
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
from __future__ import annotations
import csv
import json
import shutil
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Iterable
from sqlalchemy import select
from app.modules.billing.models import BillingInvoice, BillingInvoiceLine, BillingPayment, BillingInvoiceGenerationBatch
from app.modules.clients.models import Client
from app.modules.core.tenancy.models import FinancialYear, YearBackupExport
from app.modules.documents.models import EngagementDocument, EngagementDocumentVersion
from app.modules.notice_cases.models import NoticeCase, NoticeCaseDocument, NoticeCaseEvent, NoticeCaseHearing, NoticeCaseOrder
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance
BACKUP_ROOT = Path("data/year_backups")
def _safe_name(value: str) -> str:
return "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in str(value or "")).strip("_") or "export"
def _serialise(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (datetime,)):
return value.isoformat()
if hasattr(value, "isoformat"):
try:
return value.isoformat()
except Exception:
pass
return str(value)
def _columns(model: Any) -> list[str]:
return [column.name for column in model.__table__.columns]
def _write_csv(path: Path, model: Any, rows: Iterable[Any]) -> int:
cols = _columns(model)
count = 0
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=cols)
writer.writeheader()
for row in rows:
writer.writerow({col: _serialise(getattr(row, col, None)) for col in cols})
count += 1
return count
def _relative_file_candidates(row: Any) -> list[Path]:
candidates: list[Path] = []
rel = getattr(row, "local_relative_path", None)
if rel:
candidates.append(Path(str(rel)))
candidates.append(Path("documents") / str(rel))
candidates.append(Path("data") / str(rel))
return candidates
def _copy_known_files(staging_dir: Path, rows: Iterable[Any], subfolder: str) -> int:
copied = 0
target_root = staging_dir / "files" / subfolder
target_root.mkdir(parents=True, exist_ok=True)
for row in rows:
source = None
for candidate in _relative_file_candidates(row):
if candidate.exists() and candidate.is_file():
source = candidate
break
if not source:
continue
target_name = f"{getattr(row, 'id', 'file')}_{_safe_name(getattr(row, 'original_filename', None) or getattr(row, 'stored_filename', None) or source.name)}"
shutil.copy2(source, target_root / target_name)
copied += 1
return copied
def _rows_by_fy(db, model: Any, *, tenant_id: int, financial_year: str):
return db.execute(
select(model).where(model.tenant_id == tenant_id, model.financial_year == financial_year)
).scalars().all()
def build_year_backup_export(db, *, financial_year: FinancialYear, user_id: int | None) -> YearBackupExport:
BACKUP_ROOT.mkdir(parents=True, exist_ok=True)
now = datetime.now(timezone.utc)
export_code = f"FY_{_safe_name(financial_year.year_code)}_{now.strftime('%Y%m%d_%H%M%S')}"
staging_parent = BACKUP_ROOT / "_staging"
staging_parent.mkdir(parents=True, exist_ok=True)
with TemporaryDirectory(prefix=export_code + "_", dir=str(staging_parent)) as tmp:
staging = Path(tmp)
tenant_id = int(financial_year.tenant_id)
fy = financial_year.year_code
subscriptions = _rows_by_fy(db, ClientServiceSubscription, tenant_id=tenant_id, financial_year=fy)
tasks = _rows_by_fy(db, ClientServiceTaskInstance, tenant_id=tenant_id, financial_year=fy)
engagement_documents = _rows_by_fy(db, EngagementDocument, tenant_id=tenant_id, financial_year=fy)
engagement_document_ids = [row.id for row in engagement_documents]
engagement_versions = db.execute(
select(EngagementDocumentVersion).where(EngagementDocumentVersion.document_id.in_(engagement_document_ids))
).scalars().all() if engagement_document_ids else []
notice_cases = _rows_by_fy(db, NoticeCase, tenant_id=tenant_id, financial_year=fy)
notice_case_ids = [row.id for row in notice_cases]
notice_events = db.execute(select(NoticeCaseEvent).where(NoticeCaseEvent.case_id.in_(notice_case_ids))).scalars().all() if notice_case_ids else []
notice_hearings = db.execute(select(NoticeCaseHearing).where(NoticeCaseHearing.case_id.in_(notice_case_ids))).scalars().all() if notice_case_ids else []
notice_orders = db.execute(select(NoticeCaseOrder).where(NoticeCaseOrder.case_id.in_(notice_case_ids))).scalars().all() if notice_case_ids else []
notice_documents = db.execute(select(NoticeCaseDocument).where(NoticeCaseDocument.case_id.in_(notice_case_ids))).scalars().all() if notice_case_ids else []
invoices = _rows_by_fy(db, BillingInvoice, tenant_id=tenant_id, financial_year=fy)
invoice_ids = [row.id for row in invoices]
invoice_lines = db.execute(select(BillingInvoiceLine).where(BillingInvoiceLine.invoice_id.in_(invoice_ids))).scalars().all() if invoice_ids else []
payments = _rows_by_fy(db, BillingPayment, tenant_id=tenant_id, financial_year=fy)
batches = _rows_by_fy(db, BillingInvoiceGenerationBatch, tenant_id=tenant_id, financial_year=fy)
client_ids = sorted({row.client_id for row in subscriptions if getattr(row, "client_id", None)} | {row.client_id for row in notice_cases if getattr(row, "client_id", None)} | {row.client_id for row in invoices if getattr(row, "client_id", None)})
clients = db.execute(select(Client).where(Client.id.in_(client_ids))).scalars().all() if client_ids else []
manifest = {
"export_code": export_code,
"tenant_id": tenant_id,
"financial_year": fy,
"assessment_year": financial_year.assessment_year,
"generated_at_utc": now.isoformat(),
"generated_by_user_id": user_id,
"record_counts": {},
"file_counts": {},
}
datasets = [
("financial_year.csv", FinancialYear, [financial_year]),
("clients.csv", Client, clients),
("engagements.csv", ClientServiceSubscription, subscriptions),
("tasks.csv", ClientServiceTaskInstance, tasks),
("engagement_documents.csv", EngagementDocument, engagement_documents),
("engagement_document_versions.csv", EngagementDocumentVersion, engagement_versions),
("notice_cases.csv", NoticeCase, notice_cases),
("notice_case_events.csv", NoticeCaseEvent, notice_events),
("notice_case_hearings.csv", NoticeCaseHearing, notice_hearings),
("notice_case_orders.csv", NoticeCaseOrder, notice_orders),
("notice_case_documents.csv", NoticeCaseDocument, notice_documents),
("billing_batches.csv", BillingInvoiceGenerationBatch, batches),
("billing_invoices.csv", BillingInvoice, invoices),
("billing_invoice_lines.csv", BillingInvoiceLine, invoice_lines),
("billing_payments.csv", BillingPayment, payments),
]
for filename, model, rows in datasets:
manifest["record_counts"][filename] = _write_csv(staging / filename, model, rows)
manifest["file_counts"]["engagement_document_versions"] = _copy_known_files(staging, engagement_versions, "engagement_documents")
manifest["file_counts"]["notice_case_documents"] = _copy_known_files(staging, notice_documents, "notice_cases")
(staging / "manifest.json").write_text(json.dumps(manifest, indent=2, default=str), encoding="utf-8")
zip_path = BACKUP_ROOT / f"{export_code}.zip"
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for file_path in staging.rglob("*"):
if file_path.is_file():
zf.write(file_path, file_path.relative_to(staging))
export = YearBackupExport(
tenant_id=financial_year.tenant_id,
financial_year_id=financial_year.id,
year_code=financial_year.year_code,
assessment_year=financial_year.assessment_year,
export_status="completed",
export_file_path=str(zip_path),
file_size_bytes=zip_path.stat().st_size if zip_path.exists() else 0,
manifest_json=json.dumps(manifest, default=str),
generated_by_user_id=user_id,
generated_at_utc=now,
)
db.add(export)
db.flush()
return export