adding biometerc

This commit is contained in:
Flatlogic Bot 2026-02-05 13:35:04 +00:00
parent 9dfa03d69c
commit 2ad0af108e
40 changed files with 2464 additions and 559 deletions

View File

@ -61,6 +61,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'core',
'accounting',
'hr',
]
MIDDLEWARE = [

View File

@ -9,6 +9,7 @@ urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")),
path("", include("core.urls")),
path("accounting/", include("accounting.urls")),
path("hr/", include("hr.urls")),
]
if settings.DEBUG:

View File

@ -219,6 +219,46 @@
</ul>
</li>
<!-- HR Group -->
<li class="sidebar-group-header mt-2">
<a href="#hrSubmenu" data-bs-toggle="collapse" aria-expanded="{% if 'hr/' in path %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
<span>{% trans "Human Resources" %}</span>
<i class="bi bi-chevron-down chevron"></i>
</a>
<ul class="collapse list-unstyled sub-menu {% if 'hr/' in path %}show{% endif %}" id="hrSubmenu">
<li>
<a href="{% url 'hr:dashboard' %}" class="{% if url_name == 'dashboard' and 'hr/' in path %}active{% endif %}">
<i class="bi bi-speedometer"></i> {% trans "Overview" %}
</a>
</li>
<li>
<a href="{% url 'hr:employee_list' %}" class="{% if 'hr/employees' in path %}active{% endif %}">
<i class="bi bi-people-fill"></i> {% trans "Employees" %}
</a>
</li>
<li>
<a href="{% url 'hr:department_list' %}" class="{% if 'hr/departments' in path %}active{% endif %}">
<i class="bi bi-diagram-3"></i> {% trans "Departments" %}
</a>
</li>
<li>
<a href="{% url 'hr:attendance_list' %}" class="{% if 'hr/attendance' in path %}active{% endif %}">
<i class="bi bi-clock-history"></i> {% trans "Attendance" %}
</a>
</li>
<li>
<a href="{% url 'hr:leave_list' %}" class="{% if 'hr/leave' in path %}active{% endif %}">
<i class="bi bi-calendar2-range"></i> {% trans "Leave Requests" %}
</a>
</li>
<li>
<a href="{% url 'hr:device_list' %}" class="{% if 'hr/devices' in path %}active{% endif %}">
<i class="bi bi-fingerprint"></i> {% trans "Biometric Devices" %}
</a>
</li>
</ul>
</li>
<!-- Reports Group -->
<li class="sidebar-group-header mt-2">
<a href="#reportsSubmenu" data-bs-toggle="collapse" aria-expanded="{% if url_name == 'reports' or url_name == 'customer_statement' or url_name == 'supplier_statement' or url_name == 'cashflow_report' %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">

0
hr/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

34
hr/admin.py Normal file
View File

@ -0,0 +1,34 @@
from django.contrib import admin
from .models import Department, JobPosition, Employee, Attendance, LeaveRequest, BiometricDevice
@admin.register(Department)
class DepartmentAdmin(admin.ModelAdmin):
list_display = ('name_en', 'name_ar')
@admin.register(JobPosition)
class JobPositionAdmin(admin.ModelAdmin):
list_display = ('title_en', 'title_ar', 'department')
list_filter = ('department',)
@admin.register(Employee)
class EmployeeAdmin(admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'email', 'department', 'job_position', 'status')
list_filter = ('status', 'department', 'gender')
search_fields = ('first_name', 'last_name', 'email', 'phone')
@admin.register(Attendance)
class AttendanceAdmin(admin.ModelAdmin):
list_display = ('employee', 'date', 'check_in', 'check_out', 'device')
list_filter = ('date', 'employee', 'device')
@admin.register(LeaveRequest)
class LeaveRequestAdmin(admin.ModelAdmin):
list_display = ('employee', 'leave_type', 'start_date', 'end_date', 'status')
list_filter = ('status', 'leave_type')
list_editable = ('status',)
@admin.register(BiometricDevice)
class BiometricDeviceAdmin(admin.ModelAdmin):
list_display = ('name', 'ip_address', 'port', 'device_type', 'status', 'last_sync')
list_filter = ('status', 'device_type')
search_fields = ('name', 'ip_address')

6
hr/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class HrConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'hr'

View File

@ -0,0 +1,89 @@
# Generated by Django 5.2.7 on 2026-02-05 13:13
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Department',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name_en', models.CharField(max_length=100, verbose_name='Name (English)')),
('name_ar', models.CharField(max_length=100, verbose_name='Name (Arabic)')),
('description', models.TextField(blank=True, verbose_name='Description')),
],
),
migrations.CreateModel(
name='Employee',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(max_length=100, verbose_name='First Name')),
('last_name', models.CharField(max_length=100, verbose_name='Last Name')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
('phone', models.CharField(max_length=20, verbose_name='Phone Number')),
('gender', models.CharField(choices=[('M', 'Male'), ('F', 'Female')], default='M', max_length=1, verbose_name='Gender')),
('hire_date', models.DateField(default=django.utils.timezone.now, verbose_name='Hire Date')),
('salary', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Basic Salary')),
('status', models.CharField(choices=[('active', 'Active'), ('on_leave', 'On Leave'), ('terminated', 'Terminated'), ('resigned', 'Resigned')], default='active', max_length=20, verbose_name='Status')),
('address', models.TextField(blank=True, verbose_name='Address')),
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employees', to='hr.department', verbose_name='Department')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employee_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')),
],
),
migrations.CreateModel(
name='Attendance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.now, verbose_name='Date')),
('check_in', models.TimeField(blank=True, null=True, verbose_name='Check In')),
('check_out', models.TimeField(blank=True, null=True, verbose_name='Check Out')),
('notes', models.TextField(blank=True, verbose_name='Notes')),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendances', to='hr.employee', verbose_name='Employee')),
],
options={
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='JobPosition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title_en', models.CharField(max_length=100, verbose_name='Job Title (English)')),
('title_ar', models.CharField(max_length=100, verbose_name='Job Title (Arabic)')),
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to='hr.department', verbose_name='Department')),
],
),
migrations.AddField(
model_name='employee',
name='job_position',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='employees', to='hr.jobposition', verbose_name='Job Position'),
),
migrations.CreateModel(
name='LeaveRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('leave_type', models.CharField(choices=[('annual', 'Annual Leave'), ('sick', 'Sick Leave'), ('unpaid', 'Unpaid Leave'), ('maternity', 'Maternity Leave'), ('other', 'Other')], default='annual', max_length=20, verbose_name='Leave Type')),
('start_date', models.DateField(verbose_name='Start Date')),
('end_date', models.DateField(verbose_name='End Date')),
('reason', models.TextField(blank=True, verbose_name='Reason')),
('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=20, verbose_name='Status')),
('created_at', models.DateTimeField(auto_now_add=True)),
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_leaves', to=settings.AUTH_USER_MODEL, verbose_name='Approved By')),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leave_requests', to='hr.employee', verbose_name='Employee')),
],
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.7 on 2026-02-05 13:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hr', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='BiometricDevice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Device Name')),
('ip_address', models.GenericIPAddressField(verbose_name='IP Address')),
('port', models.PositiveIntegerField(default=4370, verbose_name='Port')),
('device_type', models.CharField(choices=[('zkteco', 'ZKTeco'), ('other', 'Other')], default='zkteco', max_length=20, verbose_name='Device Type')),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive')], default='active', max_length=20, verbose_name='Status')),
('last_sync', models.DateTimeField(blank=True, null=True, verbose_name='Last Sync')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.AddField(
model_name='attendance',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='attendances', to='hr.biometricdevice', verbose_name='Source Device'),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2026-02-05 13:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hr', '0002_biometricdevice_attendance_device'),
]
operations = [
migrations.AddField(
model_name='employee',
name='biometric_id',
field=models.IntegerField(blank=True, help_text='The User ID used on the attendance device', null=True, verbose_name='Biometric Device ID'),
),
migrations.AlterUniqueTogether(
name='attendance',
unique_together={('employee', 'date')},
),
]

View File

Binary file not shown.

Binary file not shown.

211
hr/models.py Normal file
View File

@ -0,0 +1,211 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from django.utils import timezone
import datetime
class Department(models.Model):
name_en = models.CharField(_("Name (English)"), max_length=100)
name_ar = models.CharField(_("Name (Arabic)"), max_length=100)
description = models.TextField(_("Description"), blank=True)
def __str__(self):
return f"{self.name_en} / {self.name_ar}"
class JobPosition(models.Model):
title_en = models.CharField(_("Job Title (English)"), max_length=100)
title_ar = models.CharField(_("Job Title (Arabic)"), max_length=100)
department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name='positions', verbose_name=_("Department"))
def __str__(self):
return f"{self.title_en} / {self.title_ar}"
class Employee(models.Model):
STATUS_CHOICES = [
('active', _('Active')),
('on_leave', _('On Leave')),
('terminated', _('Terminated')),
('resigned', _('Resigned')),
]
GENDER_CHOICES = [
('M', _('Male')),
('F', _('Female')),
]
user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='employee_profile', verbose_name=_("User Account"))
first_name = models.CharField(_("First Name"), max_length=100)
last_name = models.CharField(_("Last Name"), max_length=100)
email = models.EmailField(_("Email"), unique=True)
phone = models.CharField(_("Phone Number"), max_length=20)
gender = models.CharField(_("Gender"), max_length=1, choices=GENDER_CHOICES, default='M')
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='employees', verbose_name=_("Department"))
job_position = models.ForeignKey(JobPosition, on_delete=models.SET_NULL, null=True, blank=True, related_name='employees', verbose_name=_("Job Position"))
hire_date = models.DateField(_("Hire Date"), default=timezone.now)
salary = models.DecimalField(_("Basic Salary"), max_digits=10, decimal_places=2, default=0)
status = models.CharField(_("Status"), max_length=20, choices=STATUS_CHOICES, default='active')
address = models.TextField(_("Address"), blank=True)
date_of_birth = models.DateField(_("Date of Birth"), null=True, blank=True)
# New field for linking with biometric device
biometric_id = models.IntegerField(_("Biometric Device ID"), null=True, blank=True, help_text=_("The User ID used on the attendance device"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.first_name} {self.last_name}"
class BiometricDevice(models.Model):
DEVICE_TYPES = [
('zkteco', _('ZKTeco')),
('other', _('Other')),
]
STATUS_CHOICES = [
('active', _('Active')),
('inactive', _('Inactive')),
]
name = models.CharField(_("Device Name"), max_length=100)
ip_address = models.GenericIPAddressField(_("IP Address"))
port = models.PositiveIntegerField(_("Port"), default=4370)
device_type = models.CharField(_("Device Type"), max_length=20, choices=DEVICE_TYPES, default='zkteco')
status = models.CharField(_("Status"), max_length=20, choices=STATUS_CHOICES, default='active')
last_sync = models.DateTimeField(_("Last Sync"), null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.name} ({self.ip_address})"
def sync_data(self):
"""
Connects to the device and fetches attendance records.
Returns a summary dictionary: {'new': count, 'total': count, 'error': str}
"""
if self.device_type != 'zkteco':
return {'error': 'Only ZKTeco devices are supported for auto-sync currently.'}
from zk import ZK
conn = None
try:
conn = ZK(self.ip_address, port=self.port, timeout=10)
conn.connect()
# self.status = 'active' # Update status on successful connect
attendance_list = conn.get_attendance()
new_records = 0
for att in attendance_list:
user_id = att.user_id # String or Int depending on device
timestamp = att.timestamp
try:
# Find employee with this biometric ID
# We cast user_id to int to be safe if model is int
emp_id = int(user_id)
employee = Employee.objects.filter(biometric_id=emp_id).first()
if employee:
date = timestamp.date()
time = timestamp.time()
# Get or create attendance record for this day
attendance, created = Attendance.objects.get_or_create(
employee=employee,
date=date,
defaults={'device': self}
)
updated = False
# Logic: First punch is check_in, Last punch is check_out
if attendance.check_in is None:
attendance.check_in = time
updated = True
elif time < attendance.check_in:
# If we found an earlier time, update check_in
attendance.check_in = time
updated = True
if attendance.check_out is None and time > attendance.check_in:
attendance.check_out = time
updated = True
elif attendance.check_out and time > attendance.check_out:
attendance.check_out = time
updated = True
if created:
new_records += 1
elif updated:
attendance.save()
except ValueError:
continue # Skip if user_id is not parseable
except Exception as e:
print(f"Error processing record: {e}")
continue
self.last_sync = timezone.now()
self.status = 'active'
self.save()
return {'new': new_records, 'total': len(attendance_list), 'error': None}
except Exception as e:
self.status = 'inactive'
self.save()
return {'error': str(e)}
finally:
if conn:
conn.disconnect()
class Attendance(models.Model):
employee = models.ForeignKey(Employee, on_delete=models.CASCADE, related_name='attendances', verbose_name=_("Employee"))
date = models.DateField(_("Date"), default=timezone.now)
check_in = models.TimeField(_("Check In"), null=True, blank=True)
check_out = models.TimeField(_("Check Out"), null=True, blank=True)
notes = models.TextField(_("Notes"), blank=True)
device = models.ForeignKey(BiometricDevice, on_delete=models.SET_NULL, null=True, blank=True, related_name='attendances', verbose_name=_("Source Device"))
class Meta:
ordering = ['-date']
unique_together = ['employee', 'date'] # Prevent duplicate rows per day per employee
def __str__(self):
return f"{self.employee} - {self.date}"
class LeaveRequest(models.Model):
LEAVE_TYPES = [
('annual', _('Annual Leave')),
('sick', _('Sick Leave')),
('unpaid', _('Unpaid Leave')),
('maternity', _('Maternity Leave')),
('other', _('Other')),
]
STATUS_CHOICES = [
('pending', _('Pending')),
('approved', _('Approved')),
('rejected', _('Rejected')),
]
employee = models.ForeignKey(Employee, on_delete=models.CASCADE, related_name='leave_requests', verbose_name=_("Employee"))
leave_type = models.CharField(_("Leave Type"), max_length=20, choices=LEAVE_TYPES, default='annual')
start_date = models.DateField(_("Start Date"))
end_date = models.DateField(_("End Date"))
reason = models.TextField(_("Reason"), blank=True)
status = models.CharField(_("Status"), max_length=20, choices=STATUS_CHOICES, default='pending')
approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='approved_leaves', verbose_name=_("Approved By"))
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.employee} ({self.start_date} to {self.end_date})"
@property
def duration_days(self):
return (self.end_date - self.start_date).days + 1

View File

@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<h1 class="h3 mb-4 text-gray-800">{% trans "Attendance Records" %}</h1>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "Employee" %}</th>
<th>{% trans "Check In" %}</th>
<th>{% trans "Check Out" %}</th>
<th>{% trans "Notes" %}</th>
</tr>
</thead>
<tbody>
{% for attendance in attendances %}
<tr>
<td>{{ attendance.date }}</td>
<td>{{ attendance.employee }}</td>
<td>{{ attendance.check_in|default:"-" }}</td>
<td>{{ attendance.check_out|default:"-" }}</td>
<td>{{ attendance.notes }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center">{% trans "No records found." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 text-gray-800">{% trans "Delete Device" %}</h1>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<p>{% trans "Are you sure you want to delete the device" %} "<strong>{{ object.name }}</strong>"?</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">{% trans "Confirm Delete" %}</button>
<a href="{% url 'hr:device_list' %}" class="btn btn-secondary">{% trans "Cancel" %}</a>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,61 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 text-gray-800">{{ title }}</h1>
<a href="{% url 'hr:device_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> {% trans "Back to List" %}
</a>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Device Name" %}</label>
<input type="text" name="name" class="form-control" value="{{ form.name.value|default:'' }}" required>
{% if form.name.errors %}
<div class="text-danger small">{{ form.name.errors }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "IP Address" %}</label>
<input type="text" name="ip_address" class="form-control" value="{{ form.ip_address.value|default:'' }}" required placeholder="192.168.1.201">
{% if form.ip_address.errors %}
<div class="text-danger small">{{ form.ip_address.errors }}</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label">{% trans "Port" %}</label>
<input type="number" name="port" class="form-control" value="{{ form.port.value|default:'4370' }}" required>
{% if form.port.errors %}
<div class="text-danger small">{{ form.port.errors }}</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label">{% trans "Device Type" %}</label>
<select name="device_type" class="form-select">
{% for code, name in form.fields.device_type.choices %}
<option value="{{ code }}" {% if form.device_type.value == code %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">{% trans "Status" %}</label>
<select name="status" class="form-select">
{% for code, name in form.fields.status.choices %}
<option value="{{ code }}" {% if form.status.value == code %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
</div>
<button type="submit" class="btn btn-primary">{% trans "Save Device" %}</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,80 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 text-gray-800">{% trans "Biometric Devices" %}</h1>
<a href="{% url 'hr:device_add' %}" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> {% trans "Add Device" %}
</a>
</div>
{% if messages %}
<div class="messages mb-4">
{% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover" width="100%" cellspacing="0">
<thead>
<tr>
<th>{% trans "Device Name" %}</th>
<th>{% trans "IP Address" %}</th>
<th>{% trans "Port" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Last Sync" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<td>{{ device.name }}</td>
<td>{{ device.ip_address }}</td>
<td>{{ device.port }}</td>
<td>{{ device.get_device_type_display }}</td>
<td>
{% if device.status == 'active' %}
<span class="badge bg-success">{% trans "Active" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
{% endif %}
</td>
<td>{{ device.last_sync|default:"-" }}</td>
<td>
<a href="{% url 'hr:device_test' device.pk %}" class="btn btn-sm btn-info text-white" title="{% trans 'Test Connection' %}">
<i class="bi bi-broadcast"></i>
</a>
<a href="{% url 'hr:device_sync' device.pk %}" class="btn btn-sm btn-success" title="{% trans 'Sync Logs' %}">
<i class="bi bi-arrow-repeat"></i>
</a>
<a href="{% url 'hr:device_edit' device.pk %}" class="btn btn-sm btn-primary" title="{% trans 'Edit' %}">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'hr:device_delete' device.pk %}" class="btn btn-sm btn-danger" title="{% trans 'Delete' %}">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center">{% trans "No biometric devices configured." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<h1 class="h3 mb-4 text-gray-800">{% trans "HR Dashboard" %}</h1>
<div class="row">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
{% trans "Total Employees" %}</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ total_employees }}</div>
</div>
<div class="col-auto">
<i class="fas fa-users fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
{% trans "Active Employees" %}</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ active_employees }}</div>
</div>
<div class="col-auto">
<i class="fas fa-user-check fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
{% trans "Departments" %}</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ departments_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-building fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
{% trans "Pending Leave Requests" %}</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ pending_leaves }}</div>
</div>
<div class="col-auto">
<i class="fas fa-calendar-minus fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,37 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<h1 class="h3 mb-4 text-gray-800">{% trans "Departments" %}</h1>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th>{% trans "Name (English)" %}</th>
<th>{% trans "Name (Arabic)" %}</th>
<th>{% trans "Employees" %}</th>
</tr>
</thead>
<tbody>
{% for department in departments %}
<tr>
<td>{{ department.name_en }}</td>
<td>{{ department.name_ar }}</td>
<td>{{ department.employees.count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center">{% trans "No departments found." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<h1 class="h3 mb-4 text-gray-800">{{ employee.first_name }} {{ employee.last_name }}</h1>
<div class="row">
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{% trans "Personal Information" %}</h6>
</div>
<div class="card-body">
<p><strong>{% trans "Email" %}:</strong> {{ employee.email }}</p>
<p><strong>{% trans "Phone" %}:</strong> {{ employee.phone }}</p>
<p><strong>{% trans "Gender" %}:</strong> {{ employee.get_gender_display }}</p>
<p><strong>{% trans "Date of Birth" %}:</strong> {{ employee.date_of_birth|default:"-" }}</p>
<p><strong>{% trans "Address" %}:</strong> {{ employee.address|default:"-" }}</p>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{% trans "Job Details" %}</h6>
</div>
<div class="card-body">
<p><strong>{% trans "Department" %}:</strong> {{ employee.department.name_en }} / {{ employee.department.name_ar }}</p>
<p><strong>{% trans "Position" %}:</strong> {{ employee.job_position.title_en }} / {{ employee.job_position.title_ar }}</p>
<p><strong>{% trans "Hire Date" %}:</strong> {{ employee.hire_date }}</p>
<p><strong>{% trans "Salary" %}:</strong> {{ employee.salary }}</p>
<p><strong>{% trans "Status" %}:</strong> {{ employee.get_status_display }}</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<a href="{% url 'hr:employee_edit' employee.pk %}" class="btn btn-primary">{% trans "Edit Employee" %}</a>
<a href="{% url 'hr:employee_list' %}" class="btn btn-secondary">{% trans "Back to List" %}</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<h1 class="h3 mb-4 text-gray-800">{{ title }}</h1>
<div class="card shadow mb-4">
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
<a href="{% url 'hr:employee_list' %}" class="btn btn-secondary">{% trans "Cancel" %}</a>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">{% trans "Employees" %}</h1>
<a href="{% url 'hr:employee_add' %}" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm">
<i class="fas fa-plus fa-sm text-white-50"></i> {% trans "Add Employee" %}
</a>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Department" %}</th>
<th>{% trans "Position" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Phone" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for employee in employees %}
<tr>
<td>
<a href="{% url 'hr:employee_detail' employee.pk %}">
{{ employee.first_name }} {{ employee.last_name }}
</a>
</td>
<td>{{ employee.department.name_en }} / {{ employee.department.name_ar }}</td>
<td>{{ employee.job_position.title_en }} / {{ employee.job_position.title_ar }}</td>
<td>{{ employee.email }}</td>
<td>{{ employee.phone }}</td>
<td>
<span class="badge badge-{% if employee.status == 'active' %}success{% elif employee.status == 'terminated' %}danger{% else %}warning{% endif %}">
{{ employee.get_status_display }}
</span>
</td>
<td>
<a href="{% url 'hr:employee_edit' employee.pk %}" class="btn btn-sm btn-info">
<i class="fas fa-edit"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center">{% trans "No employees found." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<h1 class="h3 mb-4 text-gray-800">{% trans "New Leave Request" %}</h1>
<div class="card shadow mb-4">
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">{% trans "Submit Request" %}</button>
<a href="{% url 'hr:leave_list' %}" class="btn btn-secondary">{% trans "Cancel" %}</a>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">{% trans "Leave Requests" %}</h1>
<a href="{% url 'hr:leave_add' %}" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm">
<i class="fas fa-plus fa-sm text-white-50"></i> {% trans "New Request" %}
</a>
</div>
<div class="card shadow mb-4">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" width="100%" cellspacing="0">
<thead>
<tr>
<th>{% trans "Employee" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Start Date" %}</th>
<th>{% trans "End Date" %}</th>
<th>{% trans "Duration (Days)" %}</th>
<th>{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for leave in leaves %}
<tr>
<td>{{ leave.employee }}</td>
<td>{{ leave.get_leave_type_display }}</td>
<td>{{ leave.start_date }}</td>
<td>{{ leave.end_date }}</td>
<td>{{ leave.duration_days }}</td>
<td>
<span class="badge badge-{% if leave.status == 'approved' %}success{% elif leave.status == 'rejected' %}danger{% else %}warning{% endif %}">
{{ leave.get_status_display }}
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center">{% trans "No leave requests found." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

3
hr/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

24
hr/urls.py Normal file
View File

@ -0,0 +1,24 @@
from django.urls import path
from . import views
app_name = 'hr'
urlpatterns = [
path('', views.HRDashboardView.as_view(), name='dashboard'),
path('employees/', views.EmployeeListView.as_view(), name='employee_list'),
path('employees/add/', views.EmployeeCreateView.as_view(), name='employee_add'),
path('employees/<int:pk>/', views.EmployeeDetailView.as_view(), name='employee_detail'),
path('employees/<int:pk>/edit/', views.EmployeeUpdateView.as_view(), name='employee_edit'),
path('departments/', views.DepartmentListView.as_view(), name='department_list'),
path('attendance/', views.AttendanceListView.as_view(), name='attendance_list'),
path('leave/', views.LeaveRequestListView.as_view(), name='leave_list'),
path('leave/add/', views.LeaveRequestCreateView.as_view(), name='leave_add'),
# Biometric Devices
path('devices/', views.BiometricDeviceListView.as_view(), name='device_list'),
path('devices/add/', views.BiometricDeviceCreateView.as_view(), name='device_add'),
path('devices/<int:pk>/edit/', views.BiometricDeviceUpdateView.as_view(), name='device_edit'),
path('devices/<int:pk>/delete/', views.BiometricDeviceDeleteView.as_view(), name='device_delete'),
path('devices/<int:pk>/test/', views.test_device_connection, name='device_test'),
path('devices/<int:pk>/sync/', views.sync_device_logs, name='device_sync'),
]

144
hr/views.py Normal file
View File

@ -0,0 +1,144 @@
from django.views.generic import ListView, CreateView, UpdateView, TemplateView, DetailView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from .models import Employee, Department, Attendance, LeaveRequest, JobPosition, BiometricDevice
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect
from django.contrib import messages
import socket
from django.utils import timezone
class HRDashboardView(LoginRequiredMixin, TemplateView):
template_name = 'hr/dashboard.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['total_employees'] = Employee.objects.count()
context['active_employees'] = Employee.objects.filter(status='active').count()
context['departments_count'] = Department.objects.count()
context['pending_leaves'] = LeaveRequest.objects.filter(status='pending').count()
return context
class EmployeeListView(LoginRequiredMixin, ListView):
model = Employee
template_name = 'hr/employee_list.html'
context_object_name = 'employees'
class EmployeeCreateView(LoginRequiredMixin, CreateView):
model = Employee
fields = '__all__'
template_name = 'hr/employee_form.html'
success_url = reverse_lazy('hr:employee_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("Add New Employee")
return context
class EmployeeUpdateView(LoginRequiredMixin, UpdateView):
model = Employee
fields = '__all__'
template_name = 'hr/employee_form.html'
success_url = reverse_lazy('hr:employee_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("Edit Employee")
return context
class EmployeeDetailView(LoginRequiredMixin, DetailView):
model = Employee
template_name = 'hr/employee_detail.html'
class DepartmentListView(LoginRequiredMixin, ListView):
model = Department
template_name = 'hr/department_list.html'
context_object_name = 'departments'
class AttendanceListView(LoginRequiredMixin, ListView):
model = Attendance
template_name = 'hr/attendance_list.html'
context_object_name = 'attendances'
paginate_by = 50
class LeaveRequestListView(LoginRequiredMixin, ListView):
model = LeaveRequest
template_name = 'hr/leave_list.html'
context_object_name = 'leaves'
class LeaveRequestCreateView(LoginRequiredMixin, CreateView):
model = LeaveRequest
fields = ['employee', 'leave_type', 'start_date', 'end_date', 'reason']
template_name = 'hr/leave_form.html'
success_url = reverse_lazy('hr:leave_list')
class BiometricDeviceListView(LoginRequiredMixin, ListView):
model = BiometricDevice
template_name = 'hr/biometric_device_list.html'
context_object_name = 'devices'
class BiometricDeviceCreateView(LoginRequiredMixin, CreateView):
model = BiometricDevice
fields = ['name', 'ip_address', 'port', 'device_type', 'status']
template_name = 'hr/biometric_device_form.html'
success_url = reverse_lazy('hr:device_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("Add Biometric Device")
return context
class BiometricDeviceUpdateView(LoginRequiredMixin, UpdateView):
model = BiometricDevice
fields = ['name', 'ip_address', 'port', 'device_type', 'status']
template_name = 'hr/biometric_device_form.html'
success_url = reverse_lazy('hr:device_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("Edit Biometric Device")
return context
class BiometricDeviceDeleteView(LoginRequiredMixin, DeleteView):
model = BiometricDevice
template_name = 'hr/biometric_device_confirm_delete.html'
success_url = reverse_lazy('hr:device_list')
def test_device_connection(request, pk):
device = get_object_or_404(BiometricDevice, pk=pk)
try:
# Simple socket connect to see if port is open (timeout 3s)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
result = sock.connect_ex((device.ip_address, device.port))
sock.close()
if result == 0:
messages.success(request, _("Connection successful! Device is online."))
device.status = 'active'
device.last_sync = timezone.now()
device.save()
else:
# If failed, we still report it but since this is often run in dev/cloud
# where the device isn't reachable, we warn.
messages.warning(request, _("Connection failed: Port closed or host unreachable. (Error Code: %(code)s)") % {'code': result})
device.status = 'inactive'
device.save()
except Exception as e:
messages.error(request, _("Connection Error: %(error)s") % {'error': str(e)})
return redirect('hr:device_list')
def sync_device_logs(request, pk):
device = get_object_or_404(BiometricDevice, pk=pk)
result = device.sync_data() # This method we added to the model
if result.get('error'):
messages.error(request, _("Sync Failed: %(error)s") % {'error': result['error']})
else:
messages.success(request, _("Sync Successful! Fetched %(total)s records, %(new)s new.") % {'total': result['total'], 'new': result['new']})
return redirect('hr:device_list')

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
pyzk==0.9