new
parent
e77014d6eb
commit
fc986afd16
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,10 +1 @@
|
||||
from django.contrib import admin
|
||||
from .models import *
|
||||
# Register your models here.
|
||||
|
||||
admin.site.register(Ticket)
|
||||
admin.site.register(TicketStatus)
|
||||
admin.site.register(TicketRead)
|
||||
admin.site.register(TicketUpdate)
|
||||
admin.site.register(TicketAttachment)
|
||||
admin.site.register(TicketUpdateReaction)
|
@ -0,0 +1,82 @@
|
||||
# Generated by Django 4.2.5 on 2024-06-27 19:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customercore', '0023_delete_file_remove_ticketattachment_file_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='ticketattachment',
|
||||
name='ticket',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketattachment',
|
||||
name='ticket_update',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketread',
|
||||
name='ticket_update',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketread',
|
||||
name='user',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketstatus',
|
||||
name='added_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketstatus',
|
||||
name='ticket',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tickettask',
|
||||
name='task',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tickettask',
|
||||
name='ticket',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketupdate',
|
||||
name='added_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketupdate',
|
||||
name='ticket',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketupdatereaction',
|
||||
name='customer',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='ticketupdatereaction',
|
||||
name='ticket_update',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Ticket',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TicketAttachment',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TicketRead',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TicketStatus',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TicketTask',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TicketUpdate',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TicketUpdateReaction',
|
||||
),
|
||||
]
|
Binary file not shown.
@ -1,90 +0,0 @@
|
||||
from django.db import models
|
||||
from billing.models import *
|
||||
from django.utils import timezone
|
||||
# Create your models here.
|
||||
|
||||
class Ticket(models.Model):
|
||||
REGARDING_CHOICES = (
|
||||
('General/Account/Billing', 'General/Account/Billing'),
|
||||
('Project/Product', 'Project/Product'),
|
||||
)
|
||||
STATUS_CHOICES = (
|
||||
('Open', 'Open'),
|
||||
('Working On', 'Working On'),
|
||||
('Closed', 'Closed'),
|
||||
)
|
||||
ticket_number = models.CharField(max_length=400, blank=True)
|
||||
title = models.CharField(max_length=400)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, null=True)
|
||||
regarding = models.CharField(max_length=50, choices=REGARDING_CHOICES, null=True)
|
||||
project = models.ForeignKey(Project, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
product = models.ForeignKey(Item, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
departments = models.ManyToManyField(Department, null=True, blank=True)
|
||||
opened_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
opened_date = models.DateTimeField()
|
||||
customer = models.ForeignKey(CustomerProfile, on_delete=models.SET_NULL, null=True)
|
||||
ticket_members = models.ManyToManyField(StaffProfile, null=True, blank=True, related_name='ticket_members')
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.ticket_number:
|
||||
last_ticket = Ticket.objects.filter(opened_date__year=timezone.now().year).order_by('-ticket_number').first()
|
||||
if last_ticket and last_ticket.ticket_number:
|
||||
last_ticket_number = int(last_ticket.ticket_number[-4:])
|
||||
new_ticket_number = last_ticket_number + 1
|
||||
else:
|
||||
new_ticket_number = 1
|
||||
|
||||
|
||||
current_year_last_two_digits = str(timezone.now().year)[-2:]
|
||||
self.ticket_number = f"{current_year_last_two_digits}{new_ticket_number:04}"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class TicketStatus(models.Model):
|
||||
STATUS_CHOICES = (
|
||||
('Open', 'Open'),
|
||||
('Working On', 'Working On'),
|
||||
('Closed', 'Closed'),
|
||||
)
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE)
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, null=True)
|
||||
added_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
date_added = models.DateTimeField()
|
||||
|
||||
|
||||
class TicketUpdate(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
added_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
date_added = models.DateTimeField()
|
||||
|
||||
|
||||
class TicketRead(models.Model):
|
||||
ticket_update = models.ForeignKey(TicketUpdate, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
read = models.BooleanField(default=False)
|
||||
|
||||
class TicketAttachment(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, null=True, blank=True)
|
||||
ticket_update = models.ForeignKey(TicketUpdate, on_delete=models.CASCADE, null=True, blank=True)
|
||||
file_path = models.CharField(max_length=255, null=True)
|
||||
|
||||
|
||||
class TicketUpdateReaction(models.Model):
|
||||
REACTION_CHOICES = (
|
||||
('Happy', 'Happy'),
|
||||
('Indifferent', 'Indifferent'),
|
||||
('Sad', 'Sad'),
|
||||
)
|
||||
customer = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
|
||||
reaction = models.CharField(max_length=50, choices=REACTION_CHOICES, null=True)
|
||||
ticket_update = models.ForeignKey(TicketUpdate, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
|
||||
class TicketTask(models.Model):
|
||||
ticket = models.ForeignKey(TicketUpdate, on_delete=models.CASCADE)
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE)
|
||||
|
||||
|
@ -1,106 +0,0 @@
|
||||
{% load static %}
|
||||
<div class="flex gap-3 mb-10">
|
||||
<div>
|
||||
<div class="w-[45px] s:w-[60px] h-[45px] s:h-[60px] rounded-full shadow-md border border-gray-100">
|
||||
{% if update.added_by.customerprofile %}
|
||||
{% if update.added_by.customerprofile.image %}
|
||||
<img src="{{update.added_by.customerprofile.image.url}}"
|
||||
class="w-full h-full rounded-full object-cover">
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full h-full border border-secondosiblue bg-secondosiblue text-white uppercase rounded-full flex justify-center items-center p-1 shadow-md">
|
||||
{{ update.added_by.first_name.0 }}{{ update.added_by.last_name.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif update.added_by.staffprofile %}
|
||||
{% if update.added_by.staffprofile.image %}
|
||||
<img src="{{update.added_by.staffprofile.image.url}}"
|
||||
class="w-full h-full rounded-full object-cover">
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full h-full border border-osiblue bg-osiblue text-white uppercase rounded-full flex justify-center items-center p-1 shadow-md">
|
||||
{{ update.added_by.first_name.0 }}{{ update.added_by.last_name.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full replyContainer shadow-md">
|
||||
<div
|
||||
class="w-full bg-gray-100 flex justify-between items-center gap-3 px-3 py-3 cursor-pointer rounded-t-md toggleReply">
|
||||
<p class="text-secondosiblue font-light text-sm s:text-base"><span
|
||||
class="font-semibold">{{update.added_by.first_name}}</span>
|
||||
replied {{update.date_added}}</p>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-4 h-4 text-secondosiblue arrowUp">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
|
||||
</svg>
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-4 h-4 text-secondosiblue arrowDown hidden">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-white p-5 flex flex-col gap-3 reply default-css">
|
||||
{{update.description | safe }}
|
||||
|
||||
|
||||
{% if update.ticketattachment_set.all %}
|
||||
<div class="w-full flex flex-wrap justify-end items-center gap-3">
|
||||
{% for file in update.ticketattachment_set.all %}
|
||||
<div
|
||||
class="flex items-center gap-1 text-secondosiblue hover:text-gray-500 duration-300 cursor-pointer text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor" class="w-4 h-4 text-secondosiblue">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
<a href="https://osina.ositcom.com/{{file.file_path}}" target="_blank">
|
||||
{{ file.file_path | cut:"static/images/uploaded_ticket_files/" }}{% if not forloop.last %}, {% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if update.added_by.staffprofile %}
|
||||
<div class="w-full border-t border-gray-200 pt-5 flex justify-start items-center gap-3 p-5">
|
||||
<a class="text-secondosiblue font-light cursor-pointer hover:text-gray-500 duration-300">How
|
||||
did I do?</a>
|
||||
|
||||
<div class="flex justify-start items-center gap-2">
|
||||
<a href="{% url 'customeraddticketupdatereaction' 'Happy' update.id %}">
|
||||
<div
|
||||
class="w-fit h-fit rounded-full {% if update.last_customer_reaction == 'Happy' %} border-2 border-secondosiblue {% endif %}">
|
||||
<img src="{% static 'images/icons/happy-icon.png' %}"
|
||||
class="w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform">
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'customeraddticketupdatereaction' 'Indifferent' update.id %}">
|
||||
<div
|
||||
class="w-fit h-fit rounded-full {% if update.last_customer_reaction == 'Indifferent' %} border-2 border-secondosiblue {% endif %}">
|
||||
<img src="{% static 'images/icons/neutral-icon.png' %}"
|
||||
class="w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform">
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'customeraddticketupdatereaction' 'Sad' update.id %}">
|
||||
<div
|
||||
class="w-fit h-fit rounded-full {% if update.last_customer_reaction == 'Sad' %} border-2 border-secondosiblue {% endif %}">
|
||||
<img src="{% static 'images/icons/unhappy-icon.png' %}"
|
||||
class="w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform">
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
@ -1,237 +0,0 @@
|
||||
{% extends "customer_main.html" %}
|
||||
{%load static%}
|
||||
{% block content %}
|
||||
|
||||
<!-- TEXT EDITOR -->
|
||||
<style>
|
||||
@import url(https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/5.3.45/css/materialdesignicons.min.css);
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.js" defer></script>
|
||||
|
||||
|
||||
<div class="w-full px-5 s:px-9 flex flex-col gap-3">
|
||||
<div class="w-full bg-white rounded-md h-fit shadow-md p-5">
|
||||
<div class="w-full h-fit flex flex-col gap-2 bg-gray-100 shadow-md rounded-md px-3 py-3">
|
||||
<div class="w-full flex flex-col s:flex-row justify-between items-start s:items-center gap-3 mb-4 s:mb-0">
|
||||
<p class="text-secondosiblue text-[20px]">Ticket <span
|
||||
class="font-semibold">#{{ticket.ticket_number}}</span></p>
|
||||
|
||||
<button
|
||||
class="w-full s:w-fit px-3 py-2 bg-red-500 border border-red-500 text-white cursor-pointer duration-300 hover:bg-white hover:text-red-500 rounded-md closeTicketButton" data-modal-url="{% url 'closeticketstatusmodal' %}">
|
||||
Close Ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if last_ticket_status.status == 'Open' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-green-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Opened by {{last_ticket_status.added_by.first_name}} at
|
||||
{{last_ticket_status.date_added}}</p>
|
||||
</div>
|
||||
{% elif last_ticket_status.status == 'Working On' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-yellow-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Updated to 'Working On' by
|
||||
{{last_ticket_status.added_by.first_name}} at {{last_ticket_status.date_added}}</p>
|
||||
</div>
|
||||
{% elif last_ticket_status.status == 'Closed' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-red-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Closed by {{last_ticket_status.added_by.first_name}} at
|
||||
{{last_ticket_status.date_added}}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="w-full mt-3">
|
||||
<p class="text-gray-500 font-light text-sm leading-7">{{ticket.description}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if ticket.ticketattachment_set.all %}
|
||||
<div class="w-full flex flex-wrap justify-end items-center gap-3">
|
||||
{% for file in ticket.ticketattachment_set.all %}
|
||||
<div class="flex items-center gap-1 text-secondosiblue hover:text-gray-500 duration-300 cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-4 h-4 text-secondosiblue">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
<a href="https://osina.ositcom.com/{{file.file_path}}" target="_blank" class="text-sm">
|
||||
{{ file.file_path | cut:"static/images/uploaded_ticket_files/" }}{% if not forloop.last %}, {% endif %}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex flex-col mt-5 s:mt-10">
|
||||
|
||||
<div id="messages">
|
||||
{% for update in ticket_updates %}
|
||||
{% include 'details_templates/customer-ticket-message.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="typing-notification"></div>
|
||||
|
||||
|
||||
<!-- REPLYING SECTION -->
|
||||
<form id="ticketForm" class="flex gap-3"
|
||||
enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<div class="w-[45px] s:w-[60px] h-[45px] s:h-[60px] rounded-full shadow-md border border-gray-100">
|
||||
{% if request.user.customerprofile %}
|
||||
{% if request.user.customerprofile.image %}
|
||||
<img src="{{request.user.customerprofile.image.url}}"
|
||||
class="w-full h-full rounded-full object-cover">
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full h-full border border-secondosiblue bg-secondosiblue text-white uppercase rounded-full flex justify-center items-center p-1 shadow-md">
|
||||
{{ request.user.first_name.0 }}{{ request.user.last_name.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif request.user.staffprofile %}
|
||||
{% if request.user.staffprofile.image %}
|
||||
<img src="{{request.user.staffprofile.image.url}}"
|
||||
class="w-full h-full rounded-full object-cover">
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full h-full border border-osiblue bg-osiblue text-white uppercase rounded-full flex justify-center items-center p-1 shadow-md">
|
||||
{{ request.user.first_name.0 }}{{ request.user.last_name.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex flex-col gap-3">
|
||||
|
||||
<textarea name="description" rows="8" id="textEditor"
|
||||
class="w-full bg-white px-3 py-3 border border-gray-200 rounded-b-md outline-none text-gray-500 resize-none"
|
||||
placeholder="Add Comment..." required></textarea>
|
||||
|
||||
|
||||
<div class="w-full flex flex-col items-end gap-3">
|
||||
<div class="w-full s:w-fit flex flex-col s:flex-row justify-end items-center gap-3">
|
||||
<div
|
||||
class="w-full s:w-[50px] h-[50px] rounded-md bg-gray-50 shadow-md border border-gray-100 flex justify-center items-center p-2 cursor-pointer relative hover:scale-105 duration-300 transition-transform">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor"
|
||||
class="w-5 h-5 text-secondosiblue z-10 absolute pointer-events-none">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
<input type="file" id="fileupload" name="" placeholder="Select file" multiple
|
||||
class="opacity-0 absolute top-1/2 left-1/2 w-[25px] -translate-x-1/2 -translate-y-1/2 z-10">
|
||||
<select id="filePathInput" name="filePath" multiple hidden></select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full s:w-fit bg-secondosiblue border border-secondosiblue text-white rounded-md cursor-pointer hover:bg-white hover:text-secondosiblue duration-300 px-9 py-3">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="uploaded_files" class="w-full flex flex-col gap-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const ticketId = '{{ticket.id}}';
|
||||
const wsUrl = `ws://${window.location.host}/ws/ticketroom/${ticketId}/`;
|
||||
const socket = new WebSocket(wsUrl);
|
||||
let typingTimeout = null;
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket connection established');
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event_type === 'typing') {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
typingDiv.innerHTML = data.html;
|
||||
} else if (data.event_type === 'stop_typing') {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
typingDiv.innerHTML = '';
|
||||
} else {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
const messagesDiv = document.getElementById('messages');
|
||||
messagesDiv.insertAdjacentHTML('beforeend', data.html);
|
||||
typingDiv.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WebSocket connection closed');
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.log('WebSocket error:', error);
|
||||
};
|
||||
|
||||
const form = document.getElementById('ticketForm');
|
||||
const textEditor = document.getElementById('textEditor');
|
||||
|
||||
textEditor.addEventListener('input', () => {
|
||||
clearTimeout(typingTimeout);
|
||||
const data = {
|
||||
event_type: 'typing'
|
||||
};
|
||||
socket.send(JSON.stringify(data));
|
||||
|
||||
typingTimeout = setTimeout(() => {
|
||||
const stopTypingData = {
|
||||
event_type: 'stop_typing'
|
||||
};
|
||||
socket.send(JSON.stringify(stopTypingData));
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const description = formData.get('description');
|
||||
const files = formData.getAll('filePath');
|
||||
|
||||
const filePaths = [];
|
||||
for (const file of files) {
|
||||
const filePath = `/path/to/uploaded/files/${file.name}`;
|
||||
filePaths.push(filePath);
|
||||
}
|
||||
|
||||
const data = {
|
||||
event_type: 'update',
|
||||
description: description,
|
||||
filePath: filePaths
|
||||
};
|
||||
|
||||
socket.send(JSON.stringify(data));
|
||||
form.reset();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!---------------------- JS SCRIPTS -------------------->
|
||||
<script type="text/javascript" src="{% static 'js/tickets/ticket-details.js' %}"></script>
|
||||
|
||||
<script type="text/javascript" src="{% static 'js/inputs/text-editor.js' %}"></script>
|
||||
|
||||
<script type="text/javascript" src='{% static "js/inputs/file-uploader.js" %}'></script>
|
||||
|
||||
{% endblock %}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,368 +0,0 @@
|
||||
{% extends "main.html" %}
|
||||
{%load static%}
|
||||
{% block content %}
|
||||
|
||||
<!-- TEXT EDITOR -->
|
||||
<style>
|
||||
@import url(https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/5.3.45/css/materialdesignicons.min.css);
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.js" defer></script>
|
||||
<script src="https://unpkg.com/htmx.org/dist/htmx.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
|
||||
|
||||
<div class="w-full xxlg1:w-[75%] bg-white h-fit rounded-md shadow-md p-5">
|
||||
<div class="w-full h-fit flex flex-col gap-2 bg-gray-100 shadow-md rounded-md px-3 py-3">
|
||||
<div class="w-full flex flex-col md:flex-row justify-between items-start md:items-center gap-3 mb-5 md:mb-0">
|
||||
<p class="text-secondosiblue text-[20px]">Ticket <span class="font-semibold">#{{ticket.ticket_number}}</span></p>
|
||||
|
||||
|
||||
<div class="w-full s:w-fit flex flex-col s:flex-row justify-end items-center gap-2">
|
||||
<button class="w-full s:w-fit px-3 py-2 bg-osiblue border border-osiblue text-white cursor-pointer duration-300 hover:bg-white hover:text-osiblue rounded-md updateTicketStatusButton" data-modal-url="{% url 'edit-ticket-status-modal' ticket.id %}">
|
||||
Update Status
|
||||
</button>
|
||||
|
||||
<button class="w-full s:w-fit px-3 py-2 bg-osiblue border border-osiblue text-white cursor-pointer duration-300 hover:bg-white hover:text-osiblue rounded-md">
|
||||
Add Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if last_ticket_status.status == 'Open' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-green-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Opened by {{last_ticket_status.added_by.first_name}} on
|
||||
{{ last_ticket_status.date_added|date:"d F Y, h:i A" }}</p>
|
||||
</div>
|
||||
{% elif last_ticket_status.status == 'Working On' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-yellow-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Updated to 'Working On' by
|
||||
{{last_ticket_status.added_by.first_name}} on {{ last_ticket_status.date_added|date:"d F Y, h:i A" }}</p>
|
||||
</div>
|
||||
{% elif last_ticket_status.status == 'Closed' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-red-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Closed by {{last_ticket_status.added_by.first_name}} on
|
||||
{{ last_ticket_status.date_added|date:"d F Y, h:i A" }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="w-full mt-3">
|
||||
<p class="text-gray-500 font-light text-sm leading-7">{{ticket.description}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if ticket.ticketattachment_set.all %}
|
||||
<div class="w-full flex flex-wrap justify-end items-center gap-3">
|
||||
{% for file in ticket.ticketattachment_set.all %}
|
||||
<div class="flex items-center gap-1 text-secondosiblue hover:text-gray-500 duration-300 cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-4 h-4 text-secondosiblue">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
<a href="https://osina.ositcom.com/{{file.file_path}}" target="_blank" class="text-sm">{{ file.file_path | cut:"static/images/uploaded_ticket_files/" }}{% if not forloop.last %}, {% endif %}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex flex-col mt-5 s:mt-10">
|
||||
<!-- REPLY 1 -->
|
||||
|
||||
<div id="messages">
|
||||
{% for update in ticket_updates %}
|
||||
{% include 'details_templates/ticket-message.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="typing-notification"></div>
|
||||
|
||||
|
||||
<!-- REPLYING SECTION -->
|
||||
<form id="ticketForm" class="flex gap-3"
|
||||
enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<div class="w-[45px] s:w-[60px] h-[45px] s:h-[60px] rounded-full shadow-md border border-gray-100">
|
||||
{% if request.user.customerprofile %}
|
||||
{% if request.user.customerprofile.image %}
|
||||
<img src="{{request.user.customerprofile.image.url}}"
|
||||
class="w-full h-full rounded-full object-cover">
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full h-full border border-secondosiblue bg-secondosiblue text-white uppercase rounded-full flex justify-center items-center p-1 shadow-md">
|
||||
{{ request.user.first_name.0 }}{{ request.user.last_name.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif request.user.staffprofile %}
|
||||
{% if request.user.staffprofile.image %}
|
||||
<img src="{{request.user.staffprofile.image.url}}" class="w-full h-full rounded-full object-cover">
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full h-full border border-osiblue bg-osiblue text-white uppercase rounded-full flex justify-center items-center p-1 shadow-md">
|
||||
{{ request.user.first_name.0 }}{{ request.user.last_name.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex flex-col gap-3">
|
||||
|
||||
<textarea name="description" rows="8" id="textEditor"
|
||||
class="w-full bg-white px-3 py-3 border border-gray-200 rounded-b-md outline-none text-gray-500 resize-none"
|
||||
placeholder="Add Comment..." required></textarea>
|
||||
|
||||
<div class="w-full flex flex-col items-end gap-3">
|
||||
<div class="w-full s:w-fit flex flex-col s:flex-row justify-end items-center gap-3">
|
||||
<div
|
||||
class="w-full s:w-[50px] h-[50px] rounded-md bg-gray-50 shadow-md border border-gray-100 flex justify-center items-center p-2 cursor-pointer relative hover:scale-105 duration-300 transition-transform">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor"
|
||||
class="w-5 h-5 text-secondosiblue z-10 absolute pointer-events-none">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
<input type="file" id="fileupload" name="" placeholder="Select file" multiple
|
||||
class="opacity-0 absolute top-1/2 left-1/2 w-[25px] -translate-x-1/2 -translate-y-1/2 z-10">
|
||||
<select id="filePathInput" name="filePath" multiple hidden></select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full s:w-fit bg-secondosiblue border border-secondosiblue text-white rounded-md cursor-pointer hover:bg-white hover:text-secondosiblue duration-300 px-9 py-3">
|
||||
Send
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full s:w-fit bg-white border border-secondosiblue text-secondosiblue rounded-md cursor-pointer hover:bg-secondosiblue hover:text-white duration-300 px-9 py-3">
|
||||
Send as Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="uploaded_files" class="w-full flex flex-col gap-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const ticketId = '{{ticket.id}}';
|
||||
const wsUrl = `ws://${window.location.host}/ws/ticketroom/${ticketId}/`;
|
||||
const socket = new WebSocket(wsUrl);
|
||||
let typingTimeout = null;
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket connection established');
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event_type === 'typing') {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
typingDiv.innerHTML = data.html;
|
||||
} else if (data.event_type === 'stop_typing') {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
typingDiv.innerHTML = '';
|
||||
} else {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
const messagesDiv = document.getElementById('messages');
|
||||
messagesDiv.insertAdjacentHTML('beforeend', data.html);
|
||||
typingDiv.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WebSocket connection closed');
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.log('WebSocket error:', error);
|
||||
};
|
||||
|
||||
const form = document.getElementById('ticketForm');
|
||||
const textEditor = document.getElementById('textEditor');
|
||||
|
||||
textEditor.addEventListener('input', () => {
|
||||
clearTimeout(typingTimeout);
|
||||
const data = {
|
||||
event_type: 'typing'
|
||||
};
|
||||
socket.send(JSON.stringify(data));
|
||||
|
||||
typingTimeout = setTimeout(() => {
|
||||
const stopTypingData = {
|
||||
event_type: 'stop_typing'
|
||||
};
|
||||
socket.send(JSON.stringify(stopTypingData));
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const description = formData.get('description');
|
||||
const files = formData.getAll('filePath');
|
||||
|
||||
const filePaths = [];
|
||||
for (const file of files) {
|
||||
const filePath = `/path/to/uploaded/files/${file.name}`;
|
||||
filePaths.push(filePath);
|
||||
}
|
||||
|
||||
const data = {
|
||||
event_type: 'update',
|
||||
description: description,
|
||||
filePath: filePaths
|
||||
};
|
||||
|
||||
socket.send(JSON.stringify(data));
|
||||
form.reset();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="w-full mt-5">
|
||||
<div
|
||||
class=" bg-gray-200 rounded-t-md flex justify-between items-center text-white text-xl font-bold h-[50px]">
|
||||
<div class="px-3">
|
||||
<p class="text-secondosiblue uppercase font-bold">Task</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex flex-col gap-3">
|
||||
{% if points %}
|
||||
{% for point in points %}
|
||||
<p class="pointId" data-point-id="{{ point.id }}" style="display: none;">{{ point.id }}</p>
|
||||
<div class="w-full flex flex-col gap-1
|
||||
{% if point.status == 'Completed' %}
|
||||
bg-green-700
|
||||
{% elif point.status == 'Working On' %}
|
||||
bg-orange-500
|
||||
{% elif point.status == 'Paused' %}
|
||||
bg-red-500
|
||||
{% else %}
|
||||
bg-slate-700
|
||||
{% endif %}
|
||||
bg-opacity-50 rounded-md shadow-md p-3 mt-4">
|
||||
<div class="w-full flex justify-between items-end pb-2 border-b border-gray-200">
|
||||
<div class="w-[380px]">
|
||||
{% if point.status == 'Completed' %}
|
||||
<p class="text-white line-through">{{point.text}}</p>
|
||||
{% else %}
|
||||
<p class="text-white">{{point.text}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end items-center gap-2">
|
||||
{% if point.status == 'Not Completed' or point.status == 'Paused' and not point.status == 'Completed' %}
|
||||
<a href="{% url 'mark_point_working_on_task_page' point.id task.id %}">
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full bg-transparent shadow-md text-white border border-white flex justify-center items-center hover:scale-105 transition-transform duration-300"
|
||||
id="startPointButton">
|
||||
<i class="fa fa-play"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if point.status == 'Working On' and not point.status == 'Completed' %}
|
||||
<a href="{% url 'mark_point_paused_task_page' point.id task.id %}">
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full bg-transparent shadow-md text-white border border-white justify-center items-center hover:scale-105 transition-transform duration-300"
|
||||
id="pausePointButton">
|
||||
<i class="fa fa-pause"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if not point.status == 'Completed' and not point.status == 'Paused' %}
|
||||
<a href="{% url 'mark_point_completed_task_page' point.id task.id %}">
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full bg-transparent shadow-md text-white border border-white flex justify-center items-center hover:scale-105 transition-transform duration-300">
|
||||
<i class="fa fa-check"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if point.status == 'Completed' %}
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full bg-transparent shadow-md text-white border border-white flex justify-center items-center opacity-30 cursor-default">
|
||||
<i class="fa fa-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if not point.status == 'Completed' %}
|
||||
<form method="post" action="{% url 'deletepointmodal' point.id task.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="w-[40px] h-[40px] bg-transparent border border-white rounded-full text-white flex justify-center items-center shadow-md hover:scale-105 transition-transform duration-300">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-3 pt-2">
|
||||
<div class="text-white text-sm">
|
||||
<p>Total Time:
|
||||
<span class="font-semibold pointTotalTime">
|
||||
<span class="hours">{{ point.total_activity_time.0 }}</span> hours,
|
||||
<span class="minutes">{{ point.total_activity_time.1 }}</span> minutes,
|
||||
<span class="seconds">{{ point.total_activity_time.2 }}</span> seconds
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-fit py-1 px-2 bg-white text-secondosiblue text-xs rounded-md shadow-md">
|
||||
{% if point.status == 'Completed' %}
|
||||
<p class="text-green-700 opacity-50">Completed</p>
|
||||
{% elif point.status == 'Working On' %}
|
||||
<p class="text-orange-500 opacity-50">Working On</p>
|
||||
{% elif point.status == 'Paused' %}
|
||||
<p class="text-red-500 opacity-50">Paused</p>
|
||||
{% else %}
|
||||
<p class="text-secondosiblue opacity-50">Created</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="w-full flex justify-center items-center p-5 text-secondosiblue text-center">
|
||||
<p>No Available Points</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!---------------------- JS SCRIPTS -------------------->
|
||||
|
||||
<script type="text/javascript" src="{% static 'js/tickets/ticket-details.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/inputs/text-editor.js' %}"></script>
|
||||
|
||||
<script type="text/javascript" src='{% static "js/inputs/file-uploader.js" %}'></script>
|
||||
|
||||
|
||||
{% endblock %}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
from .models import *
|
||||
# Register your models here.
|
||||
|
||||
admin.site.register(Ticket)
|
||||
admin.site.register(TicketStatus)
|
||||
admin.site.register(TicketRead)
|
||||
admin.site.register(TicketUpdate)
|
||||
admin.site.register(TicketAttachment)
|
||||
admin.site.register(TicketUpdateReaction)
|
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SupportConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'support'
|
@ -0,0 +1,22 @@
|
||||
from functools import wraps
|
||||
from django.shortcuts import redirect
|
||||
from osinacore.models import *
|
||||
from django.http import QueryDict
|
||||
def ticket_member_required(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
# Check if the user is logged in and is either a customer or a staff member
|
||||
if not request.user.is_authenticated or (
|
||||
not CustomerProfile.objects.filter(user=request.user) and
|
||||
not StaffProfile.objects.filter(user=request.user)):
|
||||
# Capture the 'next' page
|
||||
next_page = request.build_absolute_uri()
|
||||
query_params = QueryDict(mutable=True)
|
||||
query_params['next'] = next_page
|
||||
query_string = query_params.urlencode()
|
||||
login_url = f"/login/?{query_string}" # Change the login URL as per your project setup
|
||||
return redirect(login_url)
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
@ -0,0 +1,92 @@
|
||||
# Generated by Django 4.2.5 on 2024-06-27 19:02
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('billing', '0052_alter_orderstatus_status'),
|
||||
('osinacore', '0085_rename_date_staffposition_start_date_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Ticket',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ticket_number', models.CharField(blank=True, max_length=400)),
|
||||
('title', models.CharField(max_length=400)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('Open', 'Open'), ('Working On', 'Working On'), ('Closed', 'Closed')], max_length=50, null=True)),
|
||||
('regarding', models.CharField(choices=[('General/Account/Billing', 'General/Account/Billing'), ('Project/Product', 'Project/Product')], max_length=50, null=True)),
|
||||
('opened_date', models.DateTimeField()),
|
||||
('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='osinacore.customerprofile')),
|
||||
('departments', models.ManyToManyField(blank=True, null=True, to='osinacore.department')),
|
||||
('opened_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='billing.item')),
|
||||
('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='osinacore.project')),
|
||||
('ticket_members', models.ManyToManyField(blank=True, null=True, related_name='ticket_members', to='osinacore.staffprofile')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketUpdate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('date_added', models.DateTimeField()),
|
||||
('added_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='support.ticket')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketUpdateReaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('reaction', models.CharField(choices=[('Happy', 'Happy'), ('Indifferent', 'Indifferent'), ('Sad', 'Sad')], max_length=50, null=True)),
|
||||
('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('ticket_update', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='support.ticketupdate')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketTask',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='osinacore.task')),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='support.ticketupdate')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketStatus',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('Open', 'Open'), ('Working On', 'Working On'), ('Closed', 'Closed')], max_length=50, null=True)),
|
||||
('date_added', models.DateTimeField()),
|
||||
('added_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='support.ticket')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketRead',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('read', models.BooleanField(default=False)),
|
||||
('ticket_update', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='support.ticketupdate')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketAttachment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file_path', models.CharField(max_length=255, null=True)),
|
||||
('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='support.ticket')),
|
||||
('ticket_update', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='support.ticketupdate')),
|
||||
],
|
||||
),
|
||||
]
|
Binary file not shown.
Binary file not shown.
@ -0,0 +1,93 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
from django.db import models
|
||||
from billing.models import *
|
||||
from django.utils import timezone
|
||||
# Create your models here.
|
||||
|
||||
class Ticket(models.Model):
|
||||
REGARDING_CHOICES = (
|
||||
('General/Account/Billing', 'General/Account/Billing'),
|
||||
('Project/Product', 'Project/Product'),
|
||||
)
|
||||
STATUS_CHOICES = (
|
||||
('Open', 'Open'),
|
||||
('Working On', 'Working On'),
|
||||
('Closed', 'Closed'),
|
||||
)
|
||||
ticket_number = models.CharField(max_length=400, blank=True)
|
||||
title = models.CharField(max_length=400)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, null=True)
|
||||
regarding = models.CharField(max_length=50, choices=REGARDING_CHOICES, null=True)
|
||||
project = models.ForeignKey(Project, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
product = models.ForeignKey(Item, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
departments = models.ManyToManyField(Department, null=True, blank=True)
|
||||
opened_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
opened_date = models.DateTimeField()
|
||||
customer = models.ForeignKey(CustomerProfile, on_delete=models.SET_NULL, null=True)
|
||||
ticket_members = models.ManyToManyField(StaffProfile, null=True, blank=True, related_name='ticket_members')
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.ticket_number:
|
||||
last_ticket = Ticket.objects.filter(opened_date__year=timezone.now().year).order_by('-ticket_number').first()
|
||||
if last_ticket and last_ticket.ticket_number:
|
||||
last_ticket_number = int(last_ticket.ticket_number[-4:])
|
||||
new_ticket_number = last_ticket_number + 1
|
||||
else:
|
||||
new_ticket_number = 1
|
||||
|
||||
|
||||
current_year_last_two_digits = str(timezone.now().year)[-2:]
|
||||
self.ticket_number = f"{current_year_last_two_digits}{new_ticket_number:04}"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class TicketStatus(models.Model):
|
||||
STATUS_CHOICES = (
|
||||
('Open', 'Open'),
|
||||
('Working On', 'Working On'),
|
||||
('Closed', 'Closed'),
|
||||
)
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE)
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, null=True)
|
||||
added_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
date_added = models.DateTimeField()
|
||||
|
||||
|
||||
class TicketUpdate(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
added_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
date_added = models.DateTimeField()
|
||||
|
||||
|
||||
class TicketRead(models.Model):
|
||||
ticket_update = models.ForeignKey(TicketUpdate, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
read = models.BooleanField(default=False)
|
||||
|
||||
class TicketAttachment(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, null=True, blank=True)
|
||||
ticket_update = models.ForeignKey(TicketUpdate, on_delete=models.CASCADE, null=True, blank=True)
|
||||
file_path = models.CharField(max_length=255, null=True)
|
||||
|
||||
|
||||
class TicketUpdateReaction(models.Model):
|
||||
REACTION_CHOICES = (
|
||||
('Happy', 'Happy'),
|
||||
('Indifferent', 'Indifferent'),
|
||||
('Sad', 'Sad'),
|
||||
)
|
||||
customer = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
|
||||
reaction = models.CharField(max_length=50, choices=REACTION_CHOICES, null=True)
|
||||
ticket_update = models.ForeignKey(TicketUpdate, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
|
||||
class TicketTask(models.Model):
|
||||
ticket = models.ForeignKey(TicketUpdate, on_delete=models.CASCADE)
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE)
|
||||
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,7 +1,7 @@
|
||||
<div id="messages" hx-swap-oob="beforeend">
|
||||
|
||||
<div class="fade-in-up">
|
||||
{% include 'details_templates/ticket-message.html' %}
|
||||
{% include 'details_templates/partials/ticket-message.html' %}
|
||||
</div>
|
||||
|
||||
<style>
|
@ -0,0 +1,50 @@
|
||||
{% load static %}
|
||||
{% if update.added_by.staffprofile %}
|
||||
<div class="w-full border-t border-gray-200 pt-5 flex justify-start items-center gap-3 p-5 {% if not user.customerprofile %} hidden {% endif %}">
|
||||
<a class="text-secondosiblue font-light cursor-pointer hover:text-gray-500 duration-300">How
|
||||
did I do?</a>
|
||||
|
||||
<div class="flex justify-start items-center gap-2">
|
||||
<button data-update-id="{{ update.id }}" data-reaction="Happy"
|
||||
class="reaction-button w-fit h-fit rounded-full {% if update.ticketupdatereaction_set.all.last.reaction == 'Happy' %} border-2 border-secondosiblue {% endif %}">
|
||||
<img src="{% static 'images/icons/happy-icon.png' %}"
|
||||
class="w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform pointer-events-none">
|
||||
</button>
|
||||
|
||||
<button data-update-id="{{ update.id }}" data-reaction="Indifferent"
|
||||
class="reaction-button w-fit h-fit rounded-full {% if update.ticketupdatereaction_set.all.last.reaction == 'Indifferent' %} border-2 border-secondosiblue {% endif %}">
|
||||
<img src="{% static 'images/icons/neutral-icon.png' %}"
|
||||
class="w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform pointer-events-none">
|
||||
</button>
|
||||
|
||||
<button data-update-id="{{ update.id }}" data-reaction="Sad"
|
||||
class="reaction-button w-fit h-fit rounded-full {% if update.ticketupdatereaction_set.all.last.reaction == 'Sad' %} border-2 border-secondosiblue {% endif %}">
|
||||
<img src="{% static 'images/icons/unhappy-icon.png' %}"
|
||||
class="w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform pointer-events-none">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="submitted-reactions" class="w-full border-t border-gray-200 pt-5 flex justify-start items-center gap-3 p-5 {% if user.customerprofile %} hidden {% endif %}">
|
||||
<div class="flex justify-start items-center gap-2">
|
||||
<button data-reaction="Happy"
|
||||
class="submittedreaction-button w-fit h-fit rounded-full {% if not update.ticketupdatereaction_set.all.last.reaction == 'Happy' %} hidden {% endif %} ">
|
||||
<img src="{% static 'images/icons/happy-icon.png' %}"
|
||||
class="w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform pointer-events-none">
|
||||
</button>
|
||||
|
||||
<button data-reaction="Indifferent"
|
||||
class="submittedreaction-button w-fit h-fit rounded-full {% if not update.ticketupdatereaction_set.all.last.reaction == 'Indifferent' %} hidden {% endif %}">
|
||||
<img src="{% static 'images/icons/neutral-icon.png' %}"
|
||||
class=" w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform pointer-events-none">
|
||||
</button>
|
||||
|
||||
<button data-reaction="Sad"
|
||||
class="submittedreaction-button w-fit h-fit rounded-full {% if not update.ticketupdatereaction_set.all.last.reaction == 'Sad' %} hidden {% endif %} ">
|
||||
<img src="{% static 'images/icons/unhappy-icon.png' %}"
|
||||
class="w-[30px] h-[30px] rounded-full cursor-pointer hover:scale-105 duration-300 transition-transform pointer-events-none">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
@ -0,0 +1,400 @@
|
||||
{% extends base_template %}
|
||||
{%load static%}
|
||||
{% block content %}
|
||||
|
||||
<!-- TEXT EDITOR -->
|
||||
<style>
|
||||
@import url(https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/5.3.45/css/materialdesignicons.min.css);
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.js" defer></script>
|
||||
<script src="https://unpkg.com/htmx.org/dist/htmx.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
|
||||
|
||||
<div class="{% if request.user.customerprofile %} w-full {% else %}w-full xxlg1:w-[75%] {% endif %} bg-white h-fit rounded-md shadow-md p-5">
|
||||
<div class="w-full h-fit flex flex-col gap-2 bg-gray-100 shadow-md rounded-md px-3 py-3">
|
||||
<div class="w-full flex flex-col md:flex-row justify-between items-start md:items-center gap-3 mb-5 md:mb-0">
|
||||
<p class="text-secondosiblue text-[20px]">Ticket <span class="font-semibold">#{{ticket.ticket_number}}</span></p>
|
||||
|
||||
|
||||
<div class="w-full s:w-fit flex flex-col s:flex-row justify-end items-center gap-2">
|
||||
<button class="w-full s:w-fit px-3 py-2 bg-osiblue border border-osiblue text-white cursor-pointer duration-300 hover:bg-white hover:text-osiblue rounded-md updateTicketStatusButton" data-modal-url="{% url 'edit-ticket-status-modal' ticket.id %}">
|
||||
Update Status
|
||||
</button>
|
||||
|
||||
<button class="w-full s:w-fit px-3 py-2 bg-osiblue border border-osiblue text-white cursor-pointer duration-300 hover:bg-white hover:text-osiblue rounded-md">
|
||||
Add Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if last_ticket_status.status == 'Open' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-green-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Opened by {{last_ticket_status.added_by.first_name}} on
|
||||
{{ last_ticket_status.date_added|date:"d F Y, h:i A" }}</p>
|
||||
</div>
|
||||
{% elif last_ticket_status.status == 'Working On' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-yellow-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Updated to 'Working On' by
|
||||
{{last_ticket_status.added_by.first_name}} on {{ last_ticket_status.date_added|date:"d F Y, h:i A" }}</p>
|
||||
</div>
|
||||
{% elif last_ticket_status.status == 'Closed' %}
|
||||
<div class="flex justify-start items-center gap-1">
|
||||
<div class="w-[16px] h-[16px] rounded-full bg-red-200 shadow-md"></div>
|
||||
<p class="text-secondosiblue font-light">Closed by {{last_ticket_status.added_by.first_name}} on
|
||||
{{ last_ticket_status.date_added|date:"d F Y, h:i A" }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="w-full mt-3">
|
||||
<p class="text-gray-500 font-light text-sm leading-7">{{ticket.description}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if ticket.ticketattachment_set.all %}
|
||||
<div class="w-full flex flex-wrap justify-end items-center gap-3">
|
||||
{% for file in ticket.ticketattachment_set.all %}
|
||||
<div class="flex items-center gap-1 text-secondosiblue hover:text-gray-500 duration-300 cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-4 h-4 text-secondosiblue">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
<a href="https://osina.ositcom.com/{{file.file_path}}" target="_blank" class="text-sm">{{ file.file_path | cut:"static/images/uploaded_ticket_files/" }}{% if not forloop.last %}, {% endif %}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex flex-col mt-5 s:mt-10">
|
||||
<!-- REPLY 1 -->
|
||||
|
||||
<div id="messages">
|
||||
{% for update in ticket_updates %}
|
||||
{% include 'details_templates/partials/ticket-message.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="typing-notification"></div>
|
||||
|
||||
|
||||
<!-- REPLYING SECTION -->
|
||||
<form id="ticketForm" class="flex gap-3"
|
||||
enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<div class="w-[45px] s:w-[60px] h-[45px] s:h-[60px] rounded-full shadow-md border border-gray-100">
|
||||
{% if request.user.customerprofile %}
|
||||
{% if request.user.customerprofile.image %}
|
||||
<img src="{{request.user.customerprofile.image.url}}"
|
||||
class="w-full h-full rounded-full object-cover">
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full h-full border border-secondosiblue bg-secondosiblue text-white uppercase rounded-full flex justify-center items-center p-1 shadow-md">
|
||||
{{ request.user.first_name.0 }}{{ request.user.last_name.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif request.user.staffprofile %}
|
||||
{% if request.user.staffprofile.image %}
|
||||
<img src="{{request.user.staffprofile.image.url}}" class="w-full h-full rounded-full object-cover">
|
||||
{% else %}
|
||||
<div
|
||||
class="w-full h-full border border-osiblue bg-osiblue text-white uppercase rounded-full flex justify-center items-center p-1 shadow-md">
|
||||
{{ request.user.first_name.0 }}{{ request.user.last_name.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-full flex flex-col gap-3">
|
||||
|
||||
<textarea name="description" rows="8" id="textEditor"
|
||||
class="w-full bg-white px-3 py-3 border border-gray-200 rounded-b-md outline-none text-gray-500 resize-none"
|
||||
placeholder="Add Comment..." required></textarea>
|
||||
|
||||
<div class="w-full flex flex-col items-end gap-3">
|
||||
<div class="w-full s:w-fit flex flex-col s:flex-row justify-end items-center gap-3">
|
||||
<div
|
||||
class="w-full s:w-[50px] h-[50px] rounded-md bg-gray-50 shadow-md border border-gray-100 flex justify-center items-center p-2 cursor-pointer relative hover:scale-105 duration-300 transition-transform">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor"
|
||||
class="w-5 h-5 text-secondosiblue z-10 absolute pointer-events-none">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
<input type="file" id="fileupload" name="" placeholder="Select file" multiple
|
||||
class="opacity-0 absolute top-1/2 left-1/2 w-[25px] -translate-x-1/2 -translate-y-1/2 z-10">
|
||||
<select id="filePathInput" name="filePath" multiple hidden></select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full s:w-fit bg-secondosiblue border border-secondosiblue text-white rounded-md cursor-pointer hover:bg-white hover:text-secondosiblue duration-300 px-9 py-3">
|
||||
Send
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full s:w-fit bg-white border border-secondosiblue text-secondosiblue rounded-md cursor-pointer hover:bg-secondosiblue hover:text-white duration-300 px-9 py-3">
|
||||
Send as Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="uploaded_files" class="w-full flex flex-col gap-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const ticketId = '{{ticket.id}}';
|
||||
const wsUrl = `ws://${window.location.host}/ws/ticketroom/${ticketId}/`;
|
||||
const socket = new WebSocket(wsUrl);
|
||||
let typingTimeout = null;
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket connection established');
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event_type === 'typing') {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
typingDiv.innerHTML = data.html;
|
||||
} else if (data.event_type === 'stop_typing') {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
typingDiv.innerHTML = '';
|
||||
} else if (data.event_type === 'reaction') {
|
||||
console.log(data.reaction);
|
||||
const updateElement = document.getElementById(`update-${data.update_id}`);
|
||||
updateElement.querySelectorAll('.reaction-button').forEach(button => {
|
||||
button.classList.remove('border-2', 'border-secondosiblue');
|
||||
if (button.dataset.reaction === data.reaction) {
|
||||
button.classList.add('border-2', 'border-secondosiblue');
|
||||
}
|
||||
});
|
||||
updateElement.querySelectorAll('.submittedreaction-button').forEach(button => {
|
||||
button.classList.add('hidden');
|
||||
if (button.dataset.reaction === data.reaction) {
|
||||
button.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const typingDiv = document.getElementById('typing-notification');
|
||||
const messagesDiv = document.getElementById('messages');
|
||||
messagesDiv.insertAdjacentHTML('beforeend', data.html);
|
||||
typingDiv.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WebSocket connection closed');
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.log('WebSocket error:', error);
|
||||
};
|
||||
|
||||
const form = document.getElementById('ticketForm');
|
||||
const textEditor = document.getElementById('textEditor');
|
||||
|
||||
textEditor.addEventListener('input', () => {
|
||||
clearTimeout(typingTimeout);
|
||||
const data = {
|
||||
event_type: 'typing'
|
||||
};
|
||||
socket.send(JSON.stringify(data));
|
||||
|
||||
typingTimeout = setTimeout(() => {
|
||||
const stopTypingData = {
|
||||
event_type: 'stop_typing'
|
||||
};
|
||||
socket.send(JSON.stringify(stopTypingData));
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const description = formData.get('description');
|
||||
const files = formData.getAll('filePath');
|
||||
|
||||
const filePaths = [];
|
||||
for (const file of files) {
|
||||
const filePath = `/path/to/uploaded/files/${file.name}`;
|
||||
filePaths.push(filePath);
|
||||
}
|
||||
|
||||
const data = {
|
||||
event_type: 'update',
|
||||
description: description,
|
||||
filePath: filePaths
|
||||
};
|
||||
|
||||
socket.send(JSON.stringify(data));
|
||||
form.reset();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (event.target.classList.contains('reaction-button')) {
|
||||
const updateId = event.target.dataset.updateId;
|
||||
const reaction = event.target.dataset.reaction;
|
||||
|
||||
const data = {
|
||||
event_type: 'update_reaction',
|
||||
update_id: updateId,
|
||||
reaction: reaction
|
||||
};
|
||||
|
||||
socket.send(JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="w-full mt-5">
|
||||
<div
|
||||
class=" bg-gray-200 rounded-t-md flex justify-between items-center text-white text-xl font-bold h-[50px]">
|
||||
<div class="px-3">
|
||||
<p class="text-secondosiblue uppercase font-bold">Task</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex flex-col gap-3">
|
||||
{% if points %}
|
||||
{% for point in points %}
|
||||
<p class="pointId" data-point-id="{{ point.id }}" style="display: none;">{{ point.id }}</p>
|
||||
<div class="w-full flex flex-col gap-1
|
||||
{% if point.status == 'Completed' %}
|
||||
bg-green-700
|
||||
{% elif point.status == 'Working On' %}
|
||||
bg-orange-500
|
||||
{% elif point.status == 'Paused' %}
|
||||
bg-red-500
|
||||
{% else %}
|
||||
bg-slate-700
|
||||
{% endif %}
|
||||
bg-opacity-50 rounded-md shadow-md p-3 mt-4">
|
||||
<div class="w-full flex justify-between items-end pb-2 border-b border-gray-200">
|
||||
<div class="w-[380px]">
|
||||
{% if point.status == 'Completed' %}
|
||||
<p class="text-white line-through">{{point.text}}</p>
|
||||
{% else %}
|
||||
<p class="text-white">{{point.text}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end items-center gap-2">
|
||||
{% if point.status == 'Not Completed' or point.status == 'Paused' and not point.status == 'Completed' %}
|
||||
<a href="{% url 'mark_point_working_on_task_page' point.id task.id %}">
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full bg-transparent shadow-md text-white border border-white flex justify-center items-center hover:scale-105 transition-transform duration-300"
|
||||
id="startPointButton">
|
||||
<i class="fa fa-play"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if point.status == 'Working On' and not point.status == 'Completed' %}
|
||||
<a href="{% url 'mark_point_paused_task_page' point.id task.id %}">
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full bg-transparent shadow-md text-white border border-white justify-center items-center hover:scale-105 transition-transform duration-300"
|
||||
id="pausePointButton">
|
||||
<i class="fa fa-pause"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if not point.status == 'Completed' and not point.status == 'Paused' %}
|
||||
<a href="{% url 'mark_point_completed_task_page' point.id task.id %}">
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full bg-transparent shadow-md text-white border border-white flex justify-center items-center hover:scale-105 transition-transform duration-300">
|
||||
<i class="fa fa-check"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if point.status == 'Completed' %}
|
||||
<button
|
||||
class="w-[40px] h-[40px] rounded-full bg-transparent shadow-md text-white border border-white flex justify-center items-center opacity-30 cursor-default">
|
||||
<i class="fa fa-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if not point.status == 'Completed' %}
|
||||
<form method="post" action="{% url 'deletepointmodal' point.id task.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="w-[40px] h-[40px] bg-transparent border border-white rounded-full text-white flex justify-center items-center shadow-md hover:scale-105 transition-transform duration-300">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-3 pt-2">
|
||||
<div class="text-white text-sm">
|
||||
<p>Total Time:
|
||||
<span class="font-semibold pointTotalTime">
|
||||
<span class="hours">{{ point.total_activity_time.0 }}</span> hours,
|
||||
<span class="minutes">{{ point.total_activity_time.1 }}</span> minutes,
|
||||
<span class="seconds">{{ point.total_activity_time.2 }}</span> seconds
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-fit py-1 px-2 bg-white text-secondosiblue text-xs rounded-md shadow-md">
|
||||
{% if point.status == 'Completed' %}
|
||||
<p class="text-green-700 opacity-50">Completed</p>
|
||||
{% elif point.status == 'Working On' %}
|
||||
<p class="text-orange-500 opacity-50">Working On</p>
|
||||
{% elif point.status == 'Paused' %}
|
||||
<p class="text-red-500 opacity-50">Paused</p>
|
||||
{% else %}
|
||||
<p class="text-secondosiblue opacity-50">Created</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="w-full flex justify-center items-center p-5 text-secondosiblue text-center">
|
||||
<p>No Available Points</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!---------------------- JS SCRIPTS -------------------->
|
||||
|
||||
<script type="text/javascript" src="{% static 'js/tickets/ticket-details.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'js/inputs/text-editor.js' %}"></script>
|
||||
|
||||
<script type="text/javascript" src='{% static "js/inputs/file-uploader.js" %}'></script>
|
||||
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
@ -0,0 +1,27 @@
|
||||
"""osinaweb URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/4.1/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from support import views
|
||||
from django.conf.urls.static import static
|
||||
from django.conf import settings
|
||||
|
||||
urlpatterns = [
|
||||
path('tickets/<str:ticket_number>/', views.ticket_room, name='ticketroom'),
|
||||
|
||||
]
|
||||
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
@ -0,0 +1,28 @@
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from .models import *
|
||||
from .decorators import *
|
||||
|
||||
# Create your views here.
|
||||
@ticket_member_required
|
||||
def ticket_room(request, ticket_number):
|
||||
if hasattr(request.user, 'customerprofile'):
|
||||
base_template = "customer_main.html"
|
||||
else:
|
||||
base_template = "main.html"
|
||||
ticket = get_object_or_404(Ticket, ticket_number=ticket_number)
|
||||
ticket_updates = TicketUpdate.objects.filter(ticket=ticket).order_by('id')
|
||||
for update in TicketUpdate.objects.filter(ticket=ticket).exclude(added_by=request.user).order_by('id'):
|
||||
if not TicketRead.objects.filter(ticket_update=update, user=request.user).exists():
|
||||
TicketRead.objects.create(ticket_update=update, user=request.user, read=True)
|
||||
|
||||
last_ticket_status = TicketStatus.objects.filter(ticket=ticket).last()
|
||||
|
||||
context = {
|
||||
'base_template': base_template,
|
||||
'ticket' : ticket,
|
||||
'ticket_updates': ticket_updates,
|
||||
'last_ticket_status': last_ticket_status,
|
||||
|
||||
}
|
||||
|
||||
return render(request, 'details_templates/ticket-room.html', context)
|
Loading…
Reference in New Issue