fix: proširi bilješke na članove tima i osvježi kalendar
Widget Bilješke sada omogućuje odabir više članova tima (ne samo servisera), a backend validacija i testovi su usklađeni. Dodatno, nakon realtime service_note notifikacije sada se osvježava service-notes store, te create flow više ne briše lokalne bilješke kada odgovor vrati praznu listu. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -155,12 +155,12 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
|
|||||||
note_date = serializers.DateField(required=False, allow_null=True)
|
note_date = serializers.DateField(required=False, allow_null=True)
|
||||||
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
|
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
|
||||||
target_user = serializers.PrimaryKeyRelatedField(
|
target_user = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=User.objects.filter(is_active=True),
|
queryset=User.objects.filter(is_active=True, is_team_member=True),
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
)
|
)
|
||||||
target_users = serializers.ListField(
|
target_users = serializers.ListField(
|
||||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True)),
|
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True, is_team_member=True)),
|
||||||
required=False,
|
required=False,
|
||||||
allow_empty=False,
|
allow_empty=False,
|
||||||
)
|
)
|
||||||
@@ -203,12 +203,12 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
|
|||||||
if audience in {'self', 'team'} and target_user:
|
if audience in {'self', 'team'} and target_user:
|
||||||
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
|
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
|
||||||
if audience in {'self', 'team'} and len(target_users) > 0:
|
if audience in {'self', 'team'} and len(target_users) > 0:
|
||||||
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu servisera.'})
|
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu članova tima.'})
|
||||||
if target_user and not getattr(target_user, 'is_serviser', False):
|
if target_user and not getattr(target_user, 'is_team_member', False):
|
||||||
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo serviseru.'})
|
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo članu tima.'})
|
||||||
for selected_user in target_users:
|
for selected_user in target_users:
|
||||||
if not getattr(selected_user, 'is_serviser', False):
|
if not getattr(selected_user, 'is_team_member', False):
|
||||||
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo serviserima.'})
|
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo članovima tima.'})
|
||||||
|
|
||||||
if work_order:
|
if work_order:
|
||||||
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
|
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ class ServiceContextNotesApiTests(TestCase):
|
|||||||
is_team_member=True,
|
is_team_member=True,
|
||||||
is_serviser=True,
|
is_serviser=True,
|
||||||
)
|
)
|
||||||
|
self.team_member_non_servicer = user_model.objects.create_user(
|
||||||
|
username=f'team-{suffix}',
|
||||||
|
email=f'team-{suffix}@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_team_member=True,
|
||||||
|
is_serviser=False,
|
||||||
|
)
|
||||||
|
|
||||||
self.vehicle = Vehicle.objects.create(
|
self.vehicle = Vehicle.objects.create(
|
||||||
asset_type='crane',
|
asset_type='crane',
|
||||||
@@ -132,3 +139,31 @@ class ServiceContextNotesApiTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 201, response.content)
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
self.assertEqual(int(response.data.get('created_count', 0)), 2)
|
self.assertEqual(int(response.data.get('created_count', 0)), 2)
|
||||||
|
|
||||||
|
def test_supervisor_can_create_member_note_for_non_servicer_team_member(self):
|
||||||
|
self.client.force_authenticate(user=self.supervisor)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('service-context-note-list'),
|
||||||
|
data={
|
||||||
|
'note': 'Bilješka za team člana koji nije serviser.',
|
||||||
|
'audience': 'member',
|
||||||
|
'target_users': [str(self.team_member_non_servicer.id)],
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
|
self.assertEqual(int(response.data.get('created_count', 0)), 1)
|
||||||
|
|
||||||
|
def test_member_note_rejects_non_team_member_target(self):
|
||||||
|
self.client.force_authenticate(user=self.supervisor)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('service-context-note-list'),
|
||||||
|
data={
|
||||||
|
'note': 'Bilješka ne-smije ići van tima.',
|
||||||
|
'audience': 'member',
|
||||||
|
'target_users': [str(self.other.id)],
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400, response.content)
|
||||||
|
self.assertIn('target_users', response.data)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function ServiceNotesWidget({
|
|||||||
canManageRecipients = false,
|
canManageRecipients = false,
|
||||||
activeTasks = [],
|
activeTasks = [],
|
||||||
activeWorkOrders = [],
|
activeWorkOrders = [],
|
||||||
servicers = [],
|
teamMembers = [],
|
||||||
onCreate,
|
onCreate,
|
||||||
onClose,
|
onClose,
|
||||||
}) {
|
}) {
|
||||||
@@ -156,7 +156,7 @@ export default function ServiceNotesWidget({
|
|||||||
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
||||||
>
|
>
|
||||||
<option value="self">Notifikacija samo meni</option>
|
<option value="self">Notifikacija samo meni</option>
|
||||||
{canManageRecipients && <option value="member">Notifikacija serviserima</option>}
|
{canManageRecipients && <option value="member">Notifikacija odabranim članovima tima</option>}
|
||||||
{canManageRecipients && <option value="team">Notifikacija svim članovima tima</option>}
|
{canManageRecipients && <option value="team">Notifikacija svim članovima tima</option>}
|
||||||
</select>
|
</select>
|
||||||
{canManageRecipients && audience === 'member' && (
|
{canManageRecipients && audience === 'member' && (
|
||||||
@@ -167,10 +167,10 @@ export default function ServiceNotesWidget({
|
|||||||
const selected = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
|
const selected = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
|
||||||
setTargetUsers(selected);
|
setTargetUsers(selected);
|
||||||
}}
|
}}
|
||||||
size={Math.min(6, Math.max(3, servicers.length || 3))}
|
size={Math.min(6, Math.max(3, teamMembers.length || 3))}
|
||||||
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
||||||
>
|
>
|
||||||
{servicers.map((member) => (
|
{teamMembers.map((member) => (
|
||||||
<option key={member.id} value={member.id}>
|
<option key={member.id} value={member.id}>
|
||||||
{member.full_name || member.email}
|
{member.full_name || member.email}
|
||||||
</option>
|
</option>
|
||||||
@@ -179,7 +179,7 @@ export default function ServiceNotesWidget({
|
|||||||
)}
|
)}
|
||||||
{canManageRecipients && audience === 'member' && (
|
{canManageRecipients && audience === 'member' && (
|
||||||
<p className="text-[10px] text-text-muted">
|
<p className="text-[10px] text-text-muted">
|
||||||
Držite Ctrl (ili Cmd) za odabir više servisera.
|
Držite Ctrl (ili Cmd) za odabir više članova tima.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
|||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import { $tasks } from '../../stores/taskStore';
|
import { $tasks } from '../../stores/taskStore';
|
||||||
import { $user } from '../../stores/authStore';
|
import { $user } from '../../stores/authStore';
|
||||||
import { $workOrdersWithVehicle, fetchServicers } from '../../stores/fleetDashboardStore';
|
import { $workOrdersWithVehicle, fetchTeamMembers } from '../../stores/fleetDashboardStore';
|
||||||
import {
|
import {
|
||||||
$serviceNotes,
|
$serviceNotes,
|
||||||
$serviceNotesLoading,
|
$serviceNotesLoading,
|
||||||
@@ -30,7 +30,7 @@ export default function TaskCalendarPortal() {
|
|||||||
const notes = useStore($serviceNotes);
|
const notes = useStore($serviceNotes);
|
||||||
const notesLoading = useStore($serviceNotesLoading);
|
const notesLoading = useStore($serviceNotesLoading);
|
||||||
const user = useStore($user);
|
const user = useStore($user);
|
||||||
const [servicers, setServicers] = useState([]);
|
const [teamMembers, setTeamMembers] = useState([]);
|
||||||
|
|
||||||
const isServiser = Boolean(user?.is_serviser);
|
const isServiser = Boolean(user?.is_serviser);
|
||||||
const isTeamMember = Boolean(user?.is_team_member);
|
const isTeamMember = Boolean(user?.is_team_member);
|
||||||
@@ -82,18 +82,18 @@ export default function TaskCalendarPortal() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canManageRecipients) {
|
if (!canManageRecipients) {
|
||||||
setServicers([]);
|
setTeamMembers([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
fetchServicers()
|
fetchTeamMembers()
|
||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setServicers(Array.isArray(rows) ? rows : []);
|
setTeamMembers(Array.isArray(rows) ? rows : []);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setServicers([]);
|
setTeamMembers([]);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
@@ -123,7 +123,7 @@ export default function TaskCalendarPortal() {
|
|||||||
canManageRecipients={canManageRecipients}
|
canManageRecipients={canManageRecipients}
|
||||||
activeTasks={activeTasks}
|
activeTasks={activeTasks}
|
||||||
activeWorkOrders={activeWorkOrders}
|
activeWorkOrders={activeWorkOrders}
|
||||||
servicers={servicers}
|
teamMembers={teamMembers}
|
||||||
onCreate={createServiceNote}
|
onCreate={createServiceNote}
|
||||||
onClose={closeServiceNote}
|
onClose={closeServiceNote}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Pusher from 'pusher-js';
|
|||||||
import { api } from '../services/apiClient';
|
import { api } from '../services/apiClient';
|
||||||
import { $accessToken, $user } from './authStore';
|
import { $accessToken, $user } from './authStore';
|
||||||
import { showToast } from './toastStore';
|
import { showToast } from './toastStore';
|
||||||
|
import { fetchServiceNotes } from './serviceNotesStore';
|
||||||
|
|
||||||
export const $notifications = atom([]);
|
export const $notifications = atom([]);
|
||||||
export const $isRealtimeConnected = atom(false);
|
export const $isRealtimeConnected = atom(false);
|
||||||
@@ -84,6 +85,9 @@ function attachChannelHandlers(channel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$notifications.set([next, ...existing]);
|
$notifications.set([next, ...existing]);
|
||||||
|
if (next?.metadata?.entity_type === 'service_note') {
|
||||||
|
fetchServiceNotes().catch(() => {});
|
||||||
|
}
|
||||||
showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
|
showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function fetchServiceNotes() {
|
|||||||
export async function createServiceNote(data) {
|
export async function createServiceNote(data) {
|
||||||
const payload = await api.post('fleet/service-notes/', data ?? {});
|
const payload = await api.post('fleet/service-notes/', data ?? {});
|
||||||
const nextNotes = Array.isArray(payload?.notes) ? payload.notes : null;
|
const nextNotes = Array.isArray(payload?.notes) ? payload.notes : null;
|
||||||
if (nextNotes) {
|
if (nextNotes && nextNotes.length > 0) {
|
||||||
$serviceNotes.set(nextNotes);
|
$serviceNotes.set(nextNotes);
|
||||||
} else {
|
} else {
|
||||||
await fetchServiceNotes();
|
await fetchServiceNotes();
|
||||||
|
|||||||
Reference in New Issue
Block a user