From 14b627481f1847b6ab86808b2676173feb99d56d Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:44:44 +0200 Subject: [PATCH 001/144] adding config.lua - Config.Weapons is new. ( Which Weapons should be allowed to be registered manually ) --- config.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config.lua b/config.lua index 9978e0a8..1850829e 100644 --- a/config.lua +++ b/config.lua @@ -218,6 +218,7 @@ Config.ManagementPermissions = { 'vehicles_edit_dmv', -- Weapons 'weapons_search', + 'weapons_add', -- Cases 'cases_view', 'cases_create', @@ -333,3 +334,13 @@ Config.CameraModels = { ['cctv_cam_08'] = 'p_cctv_s', ['cctv_cam_09'] = 'hei_prop_bank_cctv_02', } + +-- Which Weapons should be allowed to be registered manually +Config.Weapons = { + { model = "weapon_heavypistol", label = "Heavy Pistol" }, + { model = "weapon_sniperrifle", label = "Hunting Rifle" }, + { model = "weapon_ceramicpistol", label = "Ceramic Pistol" }, + { model = "weapon_doubleaction", label = "Double-Action Revolver" }, + { model = "weapon_navyrevolver", label = "Navy Revolver" }, + { model = "weapon_musket", label = "Musket" }, +} \ No newline at end of file From 041707f4efd9cbc46c9feeba20155b1208526704 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:47:38 +0200 Subject: [PATCH 002/144] added Citizens.svelte and Weapons.svelte - Added fingerprint and dna fields to the Citizen interface - Both fields included in client-side filter and searchCitizens SQL query via JSON_EXTRACT from players.metadata - Extended SQL WHERE clause (8 params instead of 6), with explicit Lua loop mapping for fingerprint/DNA - Normalized vehicle plate search: whitespace stripped and uppercased on frontend and DB side (REPLACE/UPPER) - Split plateTerm and nameTerm in searchVehiclesForReport for correct case handling - Added minimum query length guard (< 2 chars) to vehicle search input --- web/src/pages/Citizens.svelte | 32 +++-- web/src/pages/Weapons.svelte | 228 +++++++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 13 deletions(-) diff --git a/web/src/pages/Citizens.svelte b/web/src/pages/Citizens.svelte index ec279964..bbf379ef 100644 --- a/web/src/pages/Citizens.svelte +++ b/web/src/pages/Citizens.svelte @@ -19,6 +19,8 @@ gender: string; dob: string; phone: string; + fingerprint?: string; + dna?: string; image?: string; occupations: string[]; properties: number; @@ -113,9 +115,10 @@ let allFilteredCitizens = $derived.by(() => { const query = searchQuery.trim().toLowerCase(); if (!query) return citizens; - return citizens.filter(({ firstName, lastName, cid, phone }) => - [firstName, lastName, cid, phone].some((val) => + return citizens.filter(({ firstName, lastName, cid, phone, fingerprint, dna }) => + [firstName, lastName, cid, phone, fingerprint, dna].some((val) => val?.toLowerCase().includes(query), + console.log(query, dna) ), ); }); @@ -543,15 +546,16 @@ type: "state" | "custom"; active: boolean; customId?: number; + description?: string; } let issuableLicenses = $derived.by((): IssuableLicense[] => { if (!selectedProfile) return []; const result: IssuableLicense[] = []; - result.push({ key: "driver", name: "Driver's License", type: "state", active: selectedProfile.licenses?.driver || false }); - result.push({ key: "weapon", name: "Weapon License", type: "state", active: selectedProfile.licenses?.weapon || false }); + result.push({ key: "driver", name: "Driver's License", type: "state", description: "State-issued license for operating motor vehicles", active: selectedProfile.licenses?.driver || false }); + result.push({ key: "weapon", name: "Weapon License", type: "state", description: "State-issued license for carrying firearms", active: selectedProfile.licenses?.weapon || false }); for (const cl of selectedProfile.customLicenses || []) { - result.push({ key: `custom-${cl.id}`, name: cl.name, type: "custom", active: cl.active, customId: cl.id }); + result.push({ key: `custom-${cl.id}`, name: cl.name, type: "custom", active: cl.active, customId: cl.id, description: cl.description }); } return result; }); @@ -1006,8 +1010,15 @@ {#each issuableLicenses as license (license.key)}
- {license.name} - {license.type === 'state' ? 'State' : 'Custom'} +
+
+ {license.name} + {license.type === 'state' ? 'State' : 'Custom'} +
+ {#if license.description} + {license.description} + {/if} +
@@ -1022,7 +1033,7 @@
@@ -1282,8 +1293,11 @@ /* License modal */ .license-modal-body { padding: 4px 0; } .license-modal-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; border-bottom: 1px solid rgba(255,255,255,0.03); } + .license-modal-row:hover { background: rgba(255,255,255,0.02); } + .license-modal-row:hover .license-modal-description { max-height: 50px; opacity: 1; } .license-modal-row:last-child { border-bottom: none; } .license-modal-info { display: flex; align-items: center; gap: 8px; } .license-modal-name { font-size: 12px; color: rgba(255,255,255,0.75); font-weight: 500; } - .license-modal-type { font-size: 8px; font-weight: 700; letter-spacing: 0.5px; padding: 1px 5px; border-radius: 3px; text-transform: uppercase; background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.25); } + .license-modal-type { font-size: 8px; font-weight: 700; letter-spacing: 0.5px; padding: 1px 5px; border-radius: 3px; text-transform: uppercase; background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.25); flex-shrink: 0; } + .license-modal-description { font-size: 10px; color: rgba(255,255,255,0.35); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; max-height: 0; opacity: 0; transition: max-height 0.8s ease, opacity 0.8s ease; } diff --git a/web/src/pages/Weapons.svelte b/web/src/pages/Weapons.svelte index 90c14e9d..8cf68ead 100644 --- a/web/src/pages/Weapons.svelte +++ b/web/src/pages/Weapons.svelte @@ -5,6 +5,11 @@ import { NUI_EVENTS } from "../constants/nuiEvents"; import { globalNotifications } from "../services/notificationService.svelte"; import Pagination from "../components/Pagination.svelte"; + import type { createTabService } from "../services/tabService.svelte"; + import type { SearchResult } from "../interfaces/IReportEditor"; + import PersonSearchModal from "../components/report-editor/PersonSearchModal.svelte"; + import { createSearchService } from "../services/searchService.svelte"; + import type { AuthService } from "../services/authService.svelte"; interface Weapon { id: number; @@ -26,6 +31,8 @@ id: number; serial: string; owner: string | null; + owner_name?: string; + changed_by_name?: string; weapon_model: string | null; weapon_class: string | null; information: string | null; @@ -44,6 +51,29 @@ let weaponPage = $state(1); let weaponPerPage = $state(25); + // ── Add Weapon modal ── + let showAddWeaponModal = $state(false); + let showWeaponOwnerSearch = $state(false); + + interface Props { + tabService: ReturnType; + authService: AuthService; + } + + let { tabService, authService }: Props = $props(); + + let canAddWeapon = $derived(authService?.hasPermission('weapons_add') ?? false); + + let weaponOptions = $state<{ model: string; label: string }[]>([]); + let addWeaponForm = $state({ + weaponModel: "", + serial: "", + owner: "", // citizenid + ownerName: "", // display name + notes: "The weapon was purchased on [DATE] in [PLACE].", + }); + const searchService = createSearchService(); + let allFilteredWeapons = $derived.by(() => { const query = searchQuery.trim().toLowerCase(); return !query @@ -79,6 +109,24 @@ } } + async function handleWeaponOwnerSearch(query: string) { + if (!query.trim()) { + searchService.clearResults(); + return; + } + try { + await searchService.searchPlayers(query); + } catch { + globalNotifications.error("Search failed"); + } + } + + function selectWeaponOwner(result: SearchResult) { + addWeaponForm.owner = result.citizenid ?? ""; + addWeaponForm.ownerName = result.fullName ?? ""; + showWeaponOwnerSearch = false; + } + async function viewWeapon(weaponId: number) { const weapon = weapons.find((item) => item.id === weaponId) || null; selectedWeapon = weapon; @@ -119,13 +167,26 @@ { id: 3, serial: 'WPN-55194', scratched: false, owner: 'Sarah Williams', information: 'Licensed for personal protection', weaponClass: 'Pistol', weaponModel: 'WEAPON_COMBATPISTOL', name: 'Combat Pistol', image: '', type: 'Handgun', seenIn: 0, flags: [], tint: 'Default' }, { id: 4, serial: 'WPN-10477', scratched: false, owner: 'David Chen', information: 'Hunting rifle, valid license', weaponClass: 'Rifle', weaponModel: 'WEAPON_MUSKET', name: 'Musket', image: '', type: 'Rifle', seenIn: 2, flags: ['Bolo'], tint: 'Default' }, ]; + weaponOptions = [ + { model: "weapon_heavypistol", label: "Schwere Pistole" }, + { model: "weapon_sniperrifle", label: "Jagdgewehr" }, + { model: "weapon_ceramicpistol", label: "Keramikpistole" }, + { model: "weapon_doubleaction", label: "Doppellauf-Revolver" }, + { model: "weapon_navyrevolver", label: "Marine-Revolver" }, + { model: "weapon_musket", label: "Muskete" }, + ]; loading = false; return; } loading = true; try { - const response = await fetchNui(NUI_EVENTS.WEAPON.GET_WEAPONS); - weapons = Array.isArray(response.weapons) ? response.weapons : []; + const [weaponsRes, configRes] = await Promise.all([ + fetchNui(NUI_EVENTS.WEAPON.GET_WEAPONS), + fetchNui<{ weapons: { model: string; label: string }[] }>(NUI_EVENTS.WEAPON.GET_WEAPON_CONFIG, {}, { weapons: [] }), + ]); + console.log('configRes:', JSON.stringify(configRes)); + weapons = Array.isArray(weaponsRes.weapons) ? weaponsRes.weapons : []; + weaponOptions = configRes.weapons ?? []; } catch (error) { globalNotifications.error("Failed to load weapons"); weapons = []; @@ -145,8 +206,102 @@ } loading = false; } + + async function addWeapon() { + if (!addWeaponForm.weaponModel.trim() || !addWeaponForm.serial.trim()) return; + + if (isEnvBrowser()) { + addWeaponForm = { weaponModel: "", serial: "", owner: "", ownerName: "", notes: "" }; + showAddWeaponModal = false; + return; + } + + const response = await fetchNui<{ success: boolean; message?: string }>( + NUI_EVENTS.WEAPON.SAVE_WEAPON_INFO, + { + serial: addWeaponForm.serial.trim(), + owner: addWeaponForm.owner, + weapModel: addWeaponForm.weaponModel.trim().toUpperCase(), + notes: addWeaponForm.notes.trim(), + } + ); + + if (response?.success) { + globalNotifications.success("Weapon saved successfully"); + refreshWeapons() + addWeaponForm = { weaponModel: "", serial: "", owner: "", ownerName: "", notes: "" }; + showAddWeaponModal = false; + } else { + globalNotifications.error(response?.message || "Failed to save weapon"); + } + } + (showWeaponOwnerSearch = false)} +/> + +{#if showAddWeaponModal} + + + +{/if} + {#if selectedWeapon}
@@ -224,17 +379,28 @@ {#each weaponHistory as entry}
- {entry.owner || 'Unknown'} + + {entry.owner_name ?? entry.owner ?? 'Unknown'} + {#if entry.owner_name && entry.owner} + ({entry.owner}) + {/if} + - {entry.weapon_model || ''} + - Model: {entry.weapon_model || ''} {entry.weapon_class ? ` · ${entry.weapon_class}` : ''} + {#if entry.information} + - Notes: {entry.information} + {/if}
{new Date(entry.created_at).toLocaleDateString()} {#if entry.reason} {entry.reason} {/if} + {#if entry.changed_by_name ?? entry.changed_by} + logged by {entry.changed_by_name ?? entry.changed_by} + {/if}
{/each} @@ -254,6 +420,11 @@ + {#if canAddWeapon} + + {/if}
@@ -415,6 +586,10 @@ cursor: not-allowed; } + /* Add Weapon button */ + .add-weapon-btn { display: flex; align-items: center; gap: 3px; background: rgba(59,130,246,0.06); border: 1px solid rgba(59,130,246,0.1); border-radius: 3px; padding: 4px 10px; color: rgba(147,197,253,0.7); font-size: 9px; font-weight: 600; cursor: pointer; transition: all 0.12s; text-transform: none; letter-spacing: 0; } + .add-weapon-btn:hover { background: rgba(59,130,246,0.12); color: rgba(147,197,253,0.9); } + /* ===== List Panel ===== */ .list-panel { flex: 1; @@ -754,4 +929,49 @@ color: rgba(255, 255, 255, 0.35); font-size: 10px; } + + .add-weapon-description { font-size: 10px; color: rgba(255,255,255,0.35); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; max-height: 0; opacity: 0; transition: max-height 0.8s ease, opacity 0.8s ease; } + .add-weapon-description.visible { max-height: 40px; opacity: 1; } + + /* Modal shared */ + .modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 999; } + .modal { background: var(--card-dark-bg); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 6px; width: min(540px, 92vw); max-height: 85vh; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); } + .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); } + .modal-header h3 { margin: 0; font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.85); } + .close-btn { display: flex; align-items: center; justify-content: center; background: transparent; color: rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); padding: 4px; border-radius: 3px; cursor: pointer; transition: all 0.1s; } + .close-btn:hover { color: rgba(255, 255, 255, 0.7); border-color: rgba(255, 255, 255, 0.1); } + + .modal-body { padding: 14px 16px; overflow-y: auto; } + .modal-top { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } + .modal-name { color: rgba(255, 255, 255, 0.85); font-size: 14px; font-weight: 700; } + .modal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } + .modal-field { display: flex; flex-direction: column; gap: 2px; } + .field-label { color: rgba(255, 255, 255, 0.35); font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; } + .field-value { color: rgba(255, 255, 255, 0.7); font-size: 11px; font-weight: 500; } + .modal-notes { background: transparent; border: none; border-top: 1px solid rgba(255, 255, 255, 0.04); border-radius: 0; padding: 10px 0 0; } + .notes-body { margin: 4px 0 0; font-size: 11px; line-height: 1.5; color: rgba(255, 255, 255, 0.5); } + + .modal-footer { display: flex; justify-content: space-between; align-items: center; gap: 6px; padding: 10px 16px; border-top: 1px solid rgba(255, 255, 255, 0.06); } + .modal-footer-left { display: flex; gap: 6px; } + .modal-footer-right { display: flex; gap: 6px; } + .resolve-btn { display: flex; align-items: center; gap: 4px; background: rgba(34, 197, 94, 0.06); color: rgba(74, 222, 128, 0.7); border: 1px solid rgba(34, 197, 94, 0.1); padding: 4px 10px; border-radius: 3px; font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } + .resolve-btn:hover { background: rgba(34, 197, 94, 0.12); color: rgba(74, 222, 128, 0.9); } + .delete-btn { display: flex; align-items: center; gap: 4px; background: transparent; color: rgba(248, 113, 113, 0.5); border: 1px solid rgba(239, 68, 68, 0.1); padding: 4px 10px; border-radius: 3px; font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } + .delete-btn:hover { background: rgba(239, 68, 68, 0.08); color: rgba(252, 165, 165, 0.8); } + .action-btn { background: rgba(var(--accent-rgb), 0.06); color: rgba(var(--accent-text-rgb), 0.7); border: 1px solid rgba(var(--accent-rgb), 0.1); padding: 4px 10px; border-radius: 3px; font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } + .action-btn:hover { background: rgba(var(--accent-rgb), 0.12); color: rgba(var(--accent-text-rgb), 0.9); } + .cancel-btn { background: transparent; color: rgba(255, 255, 255, 0.4); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 3px; padding: 4px 10px; font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } + .cancel-btn:hover { color: rgba(255, 255, 255, 0.7); border-color: rgba(255, 255, 255, 0.1); } + .primary-btn { background: rgba(16, 185, 129, 0.06); color: rgba(52, 211, 153, 0.7); border: 1px solid rgba(16, 185, 129, 0.1); border-radius: 3px; padding: 4px 12px; font-size: 10px; font-weight: 600; cursor: pointer; transition: all 0.1s; } + .primary-btn:hover { background: rgba(16, 185, 129, 0.12); color: rgba(110, 231, 183, 0.9); } + + /* Form */ + .form-body { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .form-group { display: flex; flex-direction: column; gap: 3px; } + .form-full { grid-column: 1 / -1; } + .form-input { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 3px; padding: 5px 8px; color: rgba(255, 255, 255, 0.8); font-size: 11px; transition: border-color 0.1s; font-family: inherit; } + .form-input:focus { outline: none; border-color: rgba(255, 255, 255, 0.1); } + .form-input::placeholder { color: rgba(255, 255, 255, 0.2); } + .form-select { padding-right: 22px; font-size: 10px; } + textarea.form-input { resize: vertical; min-height: 60px; } From 183a7e1b2c343e56632a00b02862e2b76cf8b6c9 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:52:26 +0200 Subject: [PATCH 003/144] added "weapons_add" into permissions added "weapons_add" as a new permission --- web/src/components/ContentArea.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/ContentArea.svelte b/web/src/components/ContentArea.svelte index e92adcd2..2811e09d 100644 --- a/web/src/components/ContentArea.svelte +++ b/web/src/components/ContentArea.svelte @@ -109,7 +109,7 @@ citizens: ["citizens_search"], bolos: ["bolos_view", "bolos_create"], vehicles: ["vehicles_search"], - weapons: ["weapons_search"], + weapons: ["weapons_search", "weapons_add"], cases: ["cases_view", "cases_create"], evidence: ["evidence_view", "evidence_create"], reports: ["reports_view", "reports_create"], @@ -208,7 +208,7 @@ {:else if activeComponent === "vehicles"} {:else if activeComponent === "weapons"} - + {:else if activeComponent === "cases"} {:else if String(activeComponent) === "evidence"} From 8956edc942c1810110bd9fa514a1658ab8b4e15d Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:53:59 +0200 Subject: [PATCH 004/144] changed search function to allow spaces/big/small letters changed search function to allow spaces/big/small letters --- web/src/components/report-editor/VehiclesManager.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/components/report-editor/VehiclesManager.svelte b/web/src/components/report-editor/VehiclesManager.svelte index b615f7c3..c41b8e45 100644 --- a/web/src/components/report-editor/VehiclesManager.svelte +++ b/web/src/components/report-editor/VehiclesManager.svelte @@ -31,7 +31,7 @@ const value = (e.target as HTMLInputElement).value; searchQuery = value; if (searchTimeout) clearTimeout(searchTimeout); - if (!value.trim()) { + if (value.trim().length < 2) { searchResults = []; return; } @@ -40,6 +40,7 @@ async function performSearch(query: string) { if (!query) return; + const normalizedQuery = query.toUpperCase().replace(/\s+/g, ''); isSearching = true; try { if (isEnvBrowser()) { @@ -50,7 +51,7 @@ } else { const results = await fetchNui( NUI_EVENTS.REPORT.SEARCH_VEHICLES_FOR_REPORT, - { query }, + { query: normalizedQuery }, [], ); searchResults = Array.isArray(results) ? results : []; From 9c49e0dc1d4060d58fa3eeabc59fd6115acf3cda Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:55:52 +0200 Subject: [PATCH 005/144] added "weapons_add" into permissions added "weapons_add" into permissions --- web/src/components/management/ManagementPermissions.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/management/ManagementPermissions.svelte b/web/src/components/management/ManagementPermissions.svelte index bade9e00..ebfc1f1f 100644 --- a/web/src/components/management/ManagementPermissions.svelte +++ b/web/src/components/management/ManagementPermissions.svelte @@ -11,7 +11,7 @@ /** Individual permissions hidden for EMS within visible categories */ const EMS_HIDDEN_PERMISSIONS = ['cameras_view', 'management_tracking']; const DOJ_VISIBLE_CATEGORIES = ['citizens', 'cases', 'evidence', 'charges', 'management']; - const DOJ_HIDDEN_PERMISSIONS = ['management_bulletins', 'management_activity', 'management_tags', 'management_tracking', 'management_settings', 'citizens_edit_licenses']; + const DOJ_HIDDEN_PERMISSIONS = ['management_bulletins', 'management_activity', 'management_tags', 'management_tracking', 'management_settings', 'citizens_edit_licenses', 'weapons_add']; let visibleCategories = $derived( jobType === 'ems' From 06d2dc474d20049917317288b343fb6697100f74 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:02:37 +0200 Subject: [PATCH 006/144] added check for fingerprint and dna added fingerprint and dna into getCitizens callback --- server/backend/citizens.lua | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/server/backend/citizens.lua b/server/backend/citizens.lua index 8cde669c..1571fc23 100644 --- a/server/backend/citizens.lua +++ b/server/backend/citizens.lua @@ -100,7 +100,9 @@ ps.registerCallback(resourceName .. ':server:getCitizens', function(source, page JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.gender')) AS gender, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.birthdate')) AS dateofbirth, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone')) AS phone, - JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job + JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job, + JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint')) AS fingerprint, + JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna')) AS dna FROM players AS p LEFT JOIN mdt_profiles AS mp ON CONVERT(p.citizenid USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(mp.citizenid USING utf8mb4) COLLATE utf8mb4_general_ci @@ -176,6 +178,8 @@ ps.registerCallback(resourceName .. ':server:getCitizens', function(source, page v.dob = v.dateofbirth v.phone = v.phone v.image = profilePics[v.citizenid] or nil + v.fingerprint = v.fingerprint or nil + v.dna = v.dna or nil v.occupations = { v.job } v.properties = propCounts[v.citizenid] or 0 v.vehicles = vehCounts[v.citizenid] or 0 @@ -216,14 +220,15 @@ ps.registerCallback(resourceName .. ':server:searchCitizens', function(source, q -- Build a complex search query that searches across multiple fields and returns same data as getCitizens local sqlQuery = [[ SELECT DISTINCT - mp.id, - p.citizenid, + mp.id, p.citizenid, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')) AS firstname, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')) AS lastname, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.gender')) AS gender, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.birthdate')) AS dateofbirth, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone')) AS phone, - JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job + JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job, + JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint')) AS fingerprint, + JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna')) AS dna FROM players AS p LEFT JOIN mdt_profiles AS mp ON p.citizenid COLLATE utf8mb4_general_ci = mp.citizenid COLLATE utf8mb4_general_ci WHERE @@ -232,13 +237,15 @@ ps.registerCallback(resourceName .. ':server:searchCitizens', function(source, q LOWER(CONCAT(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ' ', JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')))) LIKE ? OR LOWER(p.citizenid) LIKE ? OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone'))) LIKE ? OR - LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label'))) LIKE ? + LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label'))) LIKE ? OR + LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint'))) LIKE ? OR + LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna'))) LIKE ? LIMIT ? ]] - local searchLimit = Config.Pagination and Config.Pagination.CitizenSearch or 20 local result = safeQuery(sqlQuery, { - searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchLimit + searchTerm, searchTerm, searchTerm, searchTerm, + searchTerm, searchTerm, searchTerm, searchTerm }) if not result or #result == 0 then return {} end From eb4a9847f2db26f4a142b8561ff88c2abd0b9c4f Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:08:53 +0200 Subject: [PATCH 007/144] added 'weapons_add' as a new permission added 'weapons_add' as a new permission --- server/backend/management.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/backend/management.lua b/server/backend/management.lua index 458d2f47..5b3e3511 100644 --- a/server/backend/management.lua +++ b/server/backend/management.lua @@ -60,7 +60,7 @@ local function getAllPermissions() 'citizens_search', 'citizens_edit_licenses', 'bolos_view', 'bolos_create', 'vehicles_search', 'vehicles_edit_dmv', - 'weapons_search', + 'weapons_search', 'weapons_add', 'cases_view', 'cases_create', 'cases_edit', 'cases_delete', 'evidence_view', 'evidence_create', 'evidence_transfer', 'evidence_upload', 'reports_view', 'reports_create', 'reports_delete', From 0b788b32889b6a7cb90c6a6552b03f5123da23ff Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:10:04 +0200 Subject: [PATCH 008/144] added 'weapons_add' as a new permission and added new events added 'weapons_add' as a new permission and added new events --- web/src/constants/management.ts | 1 + web/src/constants/nuiEvents.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/web/src/constants/management.ts b/web/src/constants/management.ts index 1c222a0e..21d6db63 100644 --- a/web/src/constants/management.ts +++ b/web/src/constants/management.ts @@ -87,6 +87,7 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [ icon: "security", permissions: [ { key: "weapons_search", label: "Search Weapons", description: "Search and view weapon records" }, + { key: "weapons_add", label: "Add Weapons", description: "Register a new gun manually" }, ], }, { diff --git a/web/src/constants/nuiEvents.ts b/web/src/constants/nuiEvents.ts index e5175863..c049bc06 100644 --- a/web/src/constants/nuiEvents.ts +++ b/web/src/constants/nuiEvents.ts @@ -96,7 +96,9 @@ export const NUI_EVENTS = { SEARCH_WEAPONS: "searchWeapons", GET_WEAPON: "getWeapon", GET_WEAPON_HISTORY: "getWeaponOwnershipHistory", + SAVE_WEAPON_INFO: 'saveWeaponInfo', UPDATE_WEAPON: "updateWeapon", + GET_WEAPON_CONFIG: "getWeaponConfig", }, CHARGE: { GET_CHARGES: "getCharges", From 278c2ba9556288399a91f2da7a4db7e106e74c13 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:11:30 +0200 Subject: [PATCH 009/144] added 'weapons_add' as a new permission added 'weapons_add' as a new permission --- web/src/services/managementService.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/services/managementService.svelte.ts b/web/src/services/managementService.svelte.ts index 4fefd62d..c4290af7 100644 --- a/web/src/services/managementService.svelte.ts +++ b/web/src/services/managementService.svelte.ts @@ -37,7 +37,7 @@ export function createManagementService() { // Dev mock: roles with escalating permissions (labels match whatever the job grades are) const basicPerms = ["citizens_search", "bolos_view", "vehicles_search", "weapons_search", "reports_view", "warrants_view", "charges_view"]; const midPerms = [...basicPerms, "reports_create", "cases_view", "evidence_view", "bolos_create", "dispatch_attach", "dispatch_route"]; - const seniorPerms = [...midPerms, "cases_create", "cases_edit", "evidence_create", "evidence_transfer", "reports_delete", "warrants_issue", "cameras_view", "bodycams_view", "citizens_edit_licenses", "vehicles_edit_dmv"]; + const seniorPerms = [...midPerms, "cases_create", "cases_edit", "evidence_create", "evidence_transfer", "reports_delete", "warrants_issue", "cameras_view", "bodycams_view", "citizens_edit_licenses", "vehicles_edit_dmv", "weapons_add"]; const commandPerms = [...seniorPerms, "cases_delete", "evidence_upload", "warrants_close", "charges_edit", "management_activity", "management_bulletins", "roster_manage_officers", "roster_manage_certifications"]; roles = [ { key: "0", label: "Grade 0", permissions: basicPerms, isBoss: false }, From ae01a2c89b21df1069e6afc29647103e0708471b Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:15:14 +0200 Subject: [PATCH 010/144] added new permission, changed ownership history added new permission, changed ownership history --- server/backend/weapons.lua | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/server/backend/weapons.lua b/server/backend/weapons.lua index aa6d9e79..0fcd59a7 100644 --- a/server/backend/weapons.lua +++ b/server/backend/weapons.lua @@ -160,21 +160,29 @@ ps.registerCallback('ps-mdt:server:getWeapons', function(source) return { weapons = newData, bolos = weaponBolo } end) -ps.registerCallback(resourceName .. ':server:getWeaponOwnershipHistory', function(source, serial) +ps.registerCallback(resourceName .. ':server:getWeaponOwnershipHistory', function(source, payload) local src = source - if not CheckAuth(src) then return end + if not CheckAuth(src) then return {} end + + payload = payload or {} + local serial = payload.serial + if not serial or serial == '' then return {} end local rows = MySQL.query.await([[ - SELECT id, serial, owner, weapon_model, weapon_class, information, changed_by, reason, created_at - FROM mdt_weapon_ownership_history - WHERE serial = ? - ORDER BY created_at DESC + SELECT h.id, h.serial, h.owner, h.weapon_model, h.weapon_class, h.information, h.changed_by, h.reason, h.created_at, + p.fullname AS owner_name, + cb.fullname AS changed_by_name + FROM mdt_weapon_ownership_history h + LEFT JOIN mdt_profiles p ON p.citizenid = h.owner + LEFT JOIN mdt_profiles cb ON cb.citizenid = h.changed_by + WHERE h.serial = ? + ORDER BY h.created_at DESC ]], { serial }) + return rows or {} end) --- Save/Edit Weapon Info (from NUI) ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, payload) local src = source if not CheckAuth(src) then return { success = false, message = 'Unauthorized' } end @@ -191,6 +199,11 @@ ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, p return { success = false, message = 'Missing serial number' } end + -- Ensure profile exists before insert/update to avoid FK constraint error + -- if owner and owner ~= '' then + -- EnsureProfileExists(owner) + -- end + local existing = MySQL.single.await('SELECT id FROM mdt_weapons WHERE serial = ? LIMIT 1', { serial }) if existing then @@ -199,6 +212,11 @@ ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, p SET information = ?, owner = ?, weaponClass = ?, weaponModel = ? WHERE serial = ? ]], { notes, owner, weapClass, weapModel, serial }) + + MySQL.insert.await([[ + INSERT INTO mdt_weapon_ownership_history (serial, owner, weapon_model, weapon_class, information, changed_by, reason) + VALUES (?, ?, ?, ?, ?, ?, ?) + ]], { serial, owner, weapModel, weapClass, notes, ps.getIdentifier(src), 'ownership_transfer' }) else MySQL.insert.await([[ INSERT INTO mdt_weapons (serial, scratched, owner, information, weaponClass, weaponModel) From 3f43405f5388970cdc33dcf130d7db42d2bd5d3a Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:10:34 +0200 Subject: [PATCH 011/144] removed unwanted console.logs, changed notes and removed weaponOptions (now in config) removed unwanted console.logs, changed notes and removed weaponOptions (now in config) --- web/src/pages/Citizens.svelte | 1 - web/src/pages/Weapons.svelte | 13 ++----------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/web/src/pages/Citizens.svelte b/web/src/pages/Citizens.svelte index bbf379ef..8f263738 100644 --- a/web/src/pages/Citizens.svelte +++ b/web/src/pages/Citizens.svelte @@ -118,7 +118,6 @@ return citizens.filter(({ firstName, lastName, cid, phone, fingerprint, dna }) => [firstName, lastName, cid, phone, fingerprint, dna].some((val) => val?.toLowerCase().includes(query), - console.log(query, dna) ), ); }); diff --git a/web/src/pages/Weapons.svelte b/web/src/pages/Weapons.svelte index 8cf68ead..160d1fc6 100644 --- a/web/src/pages/Weapons.svelte +++ b/web/src/pages/Weapons.svelte @@ -167,14 +167,6 @@ { id: 3, serial: 'WPN-55194', scratched: false, owner: 'Sarah Williams', information: 'Licensed for personal protection', weaponClass: 'Pistol', weaponModel: 'WEAPON_COMBATPISTOL', name: 'Combat Pistol', image: '', type: 'Handgun', seenIn: 0, flags: [], tint: 'Default' }, { id: 4, serial: 'WPN-10477', scratched: false, owner: 'David Chen', information: 'Hunting rifle, valid license', weaponClass: 'Rifle', weaponModel: 'WEAPON_MUSKET', name: 'Musket', image: '', type: 'Rifle', seenIn: 2, flags: ['Bolo'], tint: 'Default' }, ]; - weaponOptions = [ - { model: "weapon_heavypistol", label: "Schwere Pistole" }, - { model: "weapon_sniperrifle", label: "Jagdgewehr" }, - { model: "weapon_ceramicpistol", label: "Keramikpistole" }, - { model: "weapon_doubleaction", label: "Doppellauf-Revolver" }, - { model: "weapon_navyrevolver", label: "Marine-Revolver" }, - { model: "weapon_musket", label: "Muskete" }, - ]; loading = false; return; } @@ -184,7 +176,6 @@ fetchNui(NUI_EVENTS.WEAPON.GET_WEAPONS), fetchNui<{ weapons: { model: string; label: string }[] }>(NUI_EVENTS.WEAPON.GET_WEAPON_CONFIG, {}, { weapons: [] }), ]); - console.log('configRes:', JSON.stringify(configRes)); weapons = Array.isArray(weaponsRes.weapons) ? weaponsRes.weapons : []; weaponOptions = configRes.weapons ?? []; } catch (error) { @@ -211,7 +202,7 @@ if (!addWeaponForm.weaponModel.trim() || !addWeaponForm.serial.trim()) return; if (isEnvBrowser()) { - addWeaponForm = { weaponModel: "", serial: "", owner: "", ownerName: "", notes: "" }; + addWeaponForm = { weaponModel: "", serial: "", owner: "", ownerName: "", notes: addWeaponForm.notes.trim() }; showAddWeaponModal = false; return; } @@ -229,7 +220,7 @@ if (response?.success) { globalNotifications.success("Weapon saved successfully"); refreshWeapons() - addWeaponForm = { weaponModel: "", serial: "", owner: "", ownerName: "", notes: "" }; + addWeaponForm = { weaponModel: "", serial: "", owner: "", ownerName: "", notes: addWeaponForm.notes.trim() }; showAddWeaponModal = false; } else { globalNotifications.error(response?.message || "Failed to save weapon"); From 871589ea5fc27b5209fddf0d04a773cc299fabf1 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:19:48 +0200 Subject: [PATCH 012/144] added `flags` JSON DEFAULT NULL, added `flags` JSON DEFAULT NULL, --- sql/qbcore.sql | 1 + sql/qbx.sql | 1 + 2 files changed, 2 insertions(+) diff --git a/sql/qbcore.sql b/sql/qbcore.sql index e7a47ba4..27d40e0d 100644 --- a/sql/qbcore.sql +++ b/sql/qbcore.sql @@ -566,6 +566,7 @@ CREATE TABLE IF NOT EXISTS `mdt_weapons` ( `information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, `weaponClass` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, `weaponModel` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, + `flags` JSON DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `unique_serial` (`serial`), KEY `FK_mdt_weapons_mdt_profiles` (`owner`), diff --git a/sql/qbx.sql b/sql/qbx.sql index 3cd9d15a..b148f9c4 100644 --- a/sql/qbx.sql +++ b/sql/qbx.sql @@ -566,6 +566,7 @@ CREATE TABLE IF NOT EXISTS `mdt_weapons` ( `information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `weaponClass` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `weaponModel` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `flags` JSON DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `unique_serial` (`serial`), KEY `FK_mdt_weapons_mdt_profiles` (`owner`), From 6a7b918aeec1c65b1a41b423ecec27496a8b9d1c Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:21:06 +0200 Subject: [PATCH 013/144] changes to Weapons.svelte changes to Weapons.svelte --- web/src/pages/Weapons.svelte | 216 +++++++++++++++++++++++++++++++---- 1 file changed, 196 insertions(+), 20 deletions(-) diff --git a/web/src/pages/Weapons.svelte b/web/src/pages/Weapons.svelte index 90c14e9d..00dd63c3 100644 --- a/web/src/pages/Weapons.svelte +++ b/web/src/pages/Weapons.svelte @@ -6,6 +6,11 @@ import { globalNotifications } from "../services/notificationService.svelte"; import Pagination from "../components/Pagination.svelte"; + interface WeaponFlag { + type: string; + info: string; + } + interface Weapon { id: number; serial: string; @@ -18,7 +23,7 @@ image: string; type: string; seenIn: number; - flags: string[]; + flags: WeaponFlag[]; tint: string; } @@ -41,6 +46,13 @@ let weaponHistory = $state([]); let historyLoading = $state(false); + let newFlag = $state({ type: "", info: "" }); + const PRESET_FLAGS = ["Stolen", "Wanted"]; + + const FLAG_TEMPLATES: Record = { + "Stolen": "Reported stolen on [DATE] by [NAME].", + "Wanted": "Wanted in connection with [CASE/REASON] as of [DATE].", + }; let weaponPage = $state(1); let weaponPerPage = $state(25); @@ -66,14 +78,15 @@ weaponPage = 1; }); - function getFlagClass(flag: string): string { - switch (flag) { - case "Active Warrant": - case "Dangerous": + function onFlagTypeChange() { + newFlag.info = FLAG_TEMPLATES[newFlag.type] ?? ""; + } + + function getFlagClass(flag: WeaponFlag): string { + switch (flag.type) { + case "Stolen": + case "Wanted": return "pill pill-red"; - case "Bolo": - case "Flight Risk": - return "pill pill-orange"; default: return "pill pill-grey"; } @@ -106,7 +119,55 @@ } } + async function addFlag() { + if (!newFlag.type || !selectedWeapon) return; + + const currentFlags = selectedWeapon.flags ?? []; + if (currentFlags.some(f => f.type === newFlag.type)) return; + + const updated = [...currentFlags, { type: newFlag.type, info: newFlag.info.trim() }]; + + if (isEnvBrowser()) { + selectedWeapon = { ...selectedWeapon, flags: updated }; + newFlag = { type: "", info: "" }; + return; + } + + const response = await fetchNui<{ success: boolean }>( + NUI_EVENTS.WEAPON.SAVE_WEAPON_FLAGS, + { serial: selectedWeapon.serial, flags: updated }, + ); + if (response?.success) { + selectedWeapon = { ...selectedWeapon, flags: updated }; + newFlag = { type: "", info: "" }; + } else { + globalNotifications.error("Failed to save flag"); + } + } + + async function removeFlag(type: string) { + if (!selectedWeapon) return; + const currentFlags = selectedWeapon.flags ?? []; + const updated = currentFlags.filter(f => f.type !== type); + + if (isEnvBrowser()) { + selectedWeapon = { ...selectedWeapon, flags: updated }; + return; + } + + const response = await fetchNui<{ success: boolean }>( + NUI_EVENTS.WEAPON.SAVE_WEAPON_FLAGS, + { serial: selectedWeapon.serial, flags: updated }, + ); + if (response?.success) { + selectedWeapon = { ...selectedWeapon, flags: updated }; + } else { + globalNotifications.error("Failed to remove flag"); + } + } + function closeWeapon() { + refreshWeapons() selectedWeapon = null; weaponHistory = []; } @@ -114,10 +175,10 @@ onMount(async () => { if (isEnvBrowser()) { weapons = [ - { id: 1, serial: 'WPN-48291', scratched: false, owner: 'Marcus Johnson', information: 'Registered service weapon', weaponClass: 'Pistol', weaponModel: 'WEAPON_PISTOL', name: 'Pistol', image: '', type: 'Handgun', seenIn: 3, flags: [], tint: 'Default' }, - { id: 2, serial: 'WPN-73820', scratched: true, owner: 'Unknown', information: 'Serial scratched off - found at crime scene', weaponClass: 'SMG', weaponModel: 'WEAPON_SMG', name: 'SMG', image: '', type: 'Submachine Gun', seenIn: 1, flags: ['Dangerous'], tint: 'Army' }, + { id: 1, serial: 'WPN-48291', scratched: false, owner: 'Marcus Johnson', information: 'Registered service weapon', weaponClass: 'Pistol', weaponModel: 'WEAPON_PISTOL', name: 'Pistol', image: '', type: 'Handgun', seenIn: 3, flags: [{ type: "Stolen", info: "reported Stolen" }], tint: 'Default' }, + { id: 2, serial: 'WPN-73820', scratched: true, owner: 'Unknown', information: 'Serial scratched off - found at crime scene', weaponClass: 'SMG', weaponModel: 'WEAPON_SMG', name: 'SMG', image: '', type: 'Submachine Gun', seenIn: 1, flags: [{ type: "Stolen", info: "reported Stolen" }], tint: 'Army' }, { id: 3, serial: 'WPN-55194', scratched: false, owner: 'Sarah Williams', information: 'Licensed for personal protection', weaponClass: 'Pistol', weaponModel: 'WEAPON_COMBATPISTOL', name: 'Combat Pistol', image: '', type: 'Handgun', seenIn: 0, flags: [], tint: 'Default' }, - { id: 4, serial: 'WPN-10477', scratched: false, owner: 'David Chen', information: 'Hunting rifle, valid license', weaponClass: 'Rifle', weaponModel: 'WEAPON_MUSKET', name: 'Musket', image: '', type: 'Rifle', seenIn: 2, flags: ['Bolo'], tint: 'Default' }, + { id: 4, serial: 'WPN-10477', scratched: false, owner: 'David Chen', information: 'Hunting rifle, valid license', weaponClass: 'Rifle', weaponModel: 'WEAPON_MUSKET', name: 'Musket', image: '', type: 'Rifle', seenIn: 2, flags: [{ type: "Wanted", info: "Found Bullets - Report #1337" }], tint: 'Default' }, ]; loading = false; return; @@ -164,7 +225,7 @@ Scratched {/if} {#each selectedWeapon.flags as flag} - {flag} + {flag.type} {/each}
@@ -202,16 +263,45 @@ {/if} - {#if selectedWeapon.flags && selectedWeapon.flags.length} -
-
Flags
-
+
+
Flags
+ + {#if selectedWeapon.flags?.length} +
{#each selectedWeapon.flags as flag} - {flag} + + {flag.type} + {#if flag.info} + — {flag.info} + {/if} + + {/each}
+ {/if} + +
+ + { if (e.key === 'Enter') addFlag(); }} + /> +
- {/if} +
Ownership History
@@ -284,8 +374,8 @@ {weapon.type} {weapon.tint || 'Default'} - {#each weapon.flags as flag} - {flag} + {#each weapon.flags || [] as flag} + {flag.type} {/each} @@ -693,12 +783,98 @@ padding: 16px; } + /* ===== Tags/Flags ===== */ .flags-row { display: flex; gap: 4px; flex-wrap: wrap; } + .tag-remove { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: inherit; + opacity: 0.4; + cursor: pointer; + padding: 0; + transition: opacity 0.1s; + line-height: 1; + } + + .tag-remove:hover { + opacity: 1; + } + + .flag-add-row { + display: flex; + gap: 6px; + align-items: center; + margin-top: 8px; + } + + .flag-add-row .form-select { + width: 120px; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 3px; + padding: 5px 8px; + color: rgba(255, 255, 255, 0.8); + font-size: 10px; + font-family: inherit; + } + + .flag-add-row .form-select:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.1); + } + + .flag-add-row .form-input { + flex: 1; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 3px; + padding: 5px 8px; + color: rgba(255, 255, 255, 0.8); + font-size: 11px; + font-family: inherit; + } + + .flag-add-row .form-input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.1); + } + + .flag-add-row .form-input::placeholder { + color: rgba(255, 255, 255, 0.2); + } + + .add-tag-btn { + background: rgba(16, 185, 129, 0.06); + color: rgba(52, 211, 153, 0.7); + border: 1px solid rgba(16, 185, 129, 0.1); + border-radius: 3px; + padding: 4px 10px; + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: all 0.1s; + white-space: nowrap; + } + + .add-tag-btn:hover:not(:disabled) { + background: rgba(16, 185, 129, 0.12); + color: rgba(110, 231, 183, 0.9); + } + + .add-tag-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + /* ===== History ===== */ .history-list { display: flex; From 3934eda1b90eb04566cf89762aa31d429c205e87 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:21:44 +0200 Subject: [PATCH 014/144] added RegisterNUICallback added RegisterNUICallback --- client/backend/weapons.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/backend/weapons.lua b/client/backend/weapons.lua index fb7fa3c4..79532dae 100644 --- a/client/backend/weapons.lua +++ b/client/backend/weapons.lua @@ -14,6 +14,12 @@ RegisterNUICallback('getWeaponBolos', function(data, cb) cb(result) end) +RegisterNUICallback('saveWeaponFlags', function(data, cb) + local flags = type(data.flags) == 'table' and data.flags or json.decode(data.flags) or {} + local result = ps.callback(resourceName .. ':server:saveWeaponFlags', data.serial, flags) + cb(result or { success = false }) +end) + RegisterNUICallback('getWeaponOwnershipHistory', function(data, cb) if not MDTOpen then cb({}) return end if not data or not data.serial then From a20df2a4798d0db10249f32b9c90e0608935986a Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:22:16 +0200 Subject: [PATCH 015/144] added ps-mdt:server:saveWeaponFlags added ps-mdt:server:saveWeaponFlags --- server/backend/weapons.lua | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/server/backend/weapons.lua b/server/backend/weapons.lua index aa6d9e79..903199bb 100644 --- a/server/backend/weapons.lua +++ b/server/backend/weapons.lua @@ -141,6 +141,7 @@ ps.registerCallback('ps-mdt:server:getWeapons', function(source) name = (QBCore and QBCore.Shared and QBCore.Shared.Weapons and QBCore.Shared.Weapons[GetHashKey(v.weaponModel)] and QBCore.Shared.Weapons[GetHashKey(v.weaponModel)].label) or v.weaponModel, image = 'https://docs.fivem.net/weapons/' .. v.weaponModel:upper() .. '.png', type = class[modelLower] and class[modelLower].type or 'unknown', + flags = v.flags and json.decode(v.flags) or {}, } table.insert(newData, weaponInfo) end @@ -174,6 +175,30 @@ ps.registerCallback(resourceName .. ':server:getWeaponOwnershipHistory', functio return rows or {} end) +ps.registerCallback(resourceName .. ':server:saveWeaponFlags', function(source, serial, flags) + local src = source + if not CheckAuth(src) then return { success = false } end + if not serial or serial == '' then return { success = false } end + + local decoded + if type(flags) == 'table' then + decoded = flags + elseif type(flags) == 'string' then + decoded = json.decode(flags) or {} + else + decoded = {} + end + + local encoded = json.encode(decoded) + local affected = MySQL.update.await('UPDATE mdt_weapons SET flags = ? WHERE serial = ?', { encoded, serial }) + + if affected and affected > 0 then + return { success = true } + else + return { success = false, message = 'Database error' } + end +end) + -- Save/Edit Weapon Info (from NUI) ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, payload) local src = source From 7bfa4854e28f8604a9618f51357bc6497b7cf45b Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:22:56 +0200 Subject: [PATCH 016/144] Added `SAVE_WEAPON_FLAGS` to `NUI_EVENTS.WEAPON` Added `SAVE_WEAPON_FLAGS` to `NUI_EVENTS.WEAPON` --- web/src/constants/nuiEvents.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/constants/nuiEvents.ts b/web/src/constants/nuiEvents.ts index e5175863..99a39b3a 100644 --- a/web/src/constants/nuiEvents.ts +++ b/web/src/constants/nuiEvents.ts @@ -97,6 +97,7 @@ export const NUI_EVENTS = { GET_WEAPON: "getWeapon", GET_WEAPON_HISTORY: "getWeaponOwnershipHistory", UPDATE_WEAPON: "updateWeapon", + SAVE_WEAPON_FLAGS: "saveWeaponFlags", }, CHARGE: { GET_CHARGES: "getCharges", From eedc11c68fbb5e48979045e564a99e588b920679 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 2 May 2026 00:25:21 +0200 Subject: [PATCH 017/144] changes from files to link, added hover for picture changes from files to link, added hover for picture to make it bigger --- .../report-editor/EvidenceManager.svelte | 642 +++++++++++------- 1 file changed, 402 insertions(+), 240 deletions(-) diff --git a/web/src/components/report-editor/EvidenceManager.svelte b/web/src/components/report-editor/EvidenceManager.svelte index 0949dbce..535cf8ec 100644 --- a/web/src/components/report-editor/EvidenceManager.svelte +++ b/web/src/components/report-editor/EvidenceManager.svelte @@ -1,17 +1,20 @@ + +{#if addImgOpen} + + + +{/if} + + +{#if lightboxOpen} + + + +{/if} + + + {#if galleryOpen} + + + + {/if} + + + {#if galleryAddOpen} + + + + {/if} + + + {#if lightboxOpen} + + + + {/if} + {#if vehicleDetail || vehicleDetailLoading}
{/if} + {#if showIssueLicenseModal}
{/if} + {:else}
-
+
Vehicles {selectedProfile.ownedVehicles?.length || 0}
@@ -859,12 +921,18 @@ {/if}
+
Properties {selectedProfile.propertiesList?.length || 0}
{#if selectedProfile.propertiesList && selectedProfile.propertiesList.length > 0} {#each sectionSlice(selectedProfile.propertiesList, propertiesPage) as p} -
{p.house}
+
+
{p.property_name}
+ +
{/each} {:else}
No properties
{/if}
@@ -880,6 +948,8 @@
{/if}
+ +
Weapons {selectedProfile.weapons?.length || 0}
@@ -907,6 +977,8 @@
{/if}
+ +
Evidence {selectedProfile.evidence?.length || 0}
@@ -933,6 +1005,8 @@
{/if}
+ +
Linked Reports {selectedProfile.linkedReports?.length || 0}
@@ -964,7 +1038,8 @@
- + + {#if vehicleDetail || vehicleDetailLoading} {/if} + + + {#if propertyDetail || propertyDetailLoading} + + {/if} + {#if showIssueLicenseModal} - {#if selectedVehicle.flags && selectedVehicle.flags.length} +
+
+ Notes + {#if !editingNotes} + + {/if} +
+ {#if editingNotes} + +
+
+ + +
+ 450}> + {notesValue.length}/500 + +
+ {:else} + {#if selectedVehicle.information?.trim()} +

{selectedVehicle.information}

+ {:else} +
No notes on file.
+ {/if} + {/if} +
+ + {#if selectedVehicle.flags?.filter(f => !f.toLowerCase().startsWith('status:')).length}
Flags
- {#each selectedVehicle.flags as flag} + {#each selectedVehicle.flags.filter(f => !f.toLowerCase().startsWith('status:')) as flag} {flag} {/each}
{/if} - {#if selectedVehicle.information} -
-
Information
-

{selectedVehicle.information}

-
- {/if} -
DMV Updates
@@ -571,10 +649,14 @@ {vehicle.class} 0}>{vehicle.points ?? 0} - {vehicle.status || 'Valid'} + {vehicle.status || 'Valid'} + - {#each vehicle.flags || [] as flag} + {#each (vehicle.flags || []).filter(f => !f.toLowerCase().startsWith('status:')) as flag} {flag} {/each} @@ -822,7 +904,7 @@ text-overflow: ellipsis; } - .col-class, .col-type { + .col-class { color: rgba(255, 255, 255, 0.35); font-size: 10px; } @@ -1023,20 +1105,13 @@ border-bottom: none; } - .section-title { - color: rgba(255, 255, 255, 0.35); - font-size: 9px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.6px; - margin-bottom: 8px; - } - .section-text { margin: 0; color: rgba(255, 255, 255, 0.5); font-size: 11px; line-height: 1.5; + word-break: break-word; + white-space: pre-wrap; } .flags-row { @@ -1243,57 +1318,335 @@ .state-garaged { color: rgba(var(--accent-text-rgb), 0.8) !important; } .state-impounded-state { color: rgba(251, 191, 36, 0.8) !important; } - /* MODAL FOR LINK/PROFIL PIC */ - - .img-modal-overlay { position:absolute;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100;backdrop-filter:blur(2px); } - - .img-modal { background:var(--dark-bg);border:1px solid rgba(255,255,255,0.08);border-radius:6px;width:min(360px,92vw);display:flex;flex-direction:column; } - - .img-modal-header { display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid rgba(255,255,255,0.06);font-size:12px;font-weight:600;color:rgba(255,255,255,0.85); } - - .img-modal-close { background:transparent;border:1px solid rgba(255,255,255,0.06);border-radius:3px;color:rgba(255,255,255,0.3);cursor:pointer;padding:4px;display:flex;align-items:center;justify-content:center;transition:all 0.1s; } - - .img-modal-close:hover { color:rgba(255,255,255,0.7);border-color:rgba(255,255,255,0.1); } - - .img-modal-body { padding:14px 16px;display:flex;flex-direction:column;gap:6px; } - - .img-modal-label { color:rgba(255,255,255,0.35);font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.6px; } - - .img-modal-input { background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:3px;padding:5px 8px;color:rgba(255,255,255,0.8);font-size:11px;font-family:inherit;width:100%; } - - .img-modal-input:focus { outline:none;border-color:rgba(255,255,255,0.1); } - - .img-modal-input::placeholder { color:rgba(255,255,255,0.2); } - - .img-modal-hint { display:flex;align-items:center;gap:5px;font-size:10px;color:rgba(255,255,255,0.25);line-height:1.4; } - - .img-modal-hint a { color:rgba(var(--accent-text-rgb),0.5);text-decoration:none;transition:color 0.1s; } - - .img-modal-hint a:hover { color:rgba(var(--accent-text-rgb),0.85);text-decoration:underline; } - - .img-modal-footer { display:flex;justify-content:flex-end;gap:6px;padding:10px 16px;border-top:1px solid rgba(255,255,255,0.06); } - - .img-modal-cancel { background:transparent;border:1px solid rgba(255,255,255,0.06);border-radius:3px;padding:4px 10px;color:rgba(255,255,255,0.4);font-size:10px;font-weight:500;cursor:pointer;transition:all 0.1s; } - - .img-modal-cancel:hover:not(:disabled) { color:rgba(255,255,255,0.7);border-color:rgba(255,255,255,0.1); } - - .img-modal-cancel:disabled { opacity:0.4;cursor:not-allowed; } - - .img-modal-confirm { background:rgba(16,185,129,0.06);color:rgba(52,211,153,0.7);border:1px solid rgba(16,185,129,0.1);border-radius:3px;padding:4px 12px;font-size:10px;font-weight:600;cursor:pointer;transition:all 0.1s; } - - .img-modal-confirm:hover:not(:disabled) { background:rgba(16,185,129,0.12);color:rgba(110,231,183,0.9); } - - .img-modal-confirm:disabled { opacity:0.4;cursor:not-allowed; } - - .img-edit-btn { position: absolute; bottom: -1px; right: -1px; width: 22px; height: 22px; background: rgba(0,0,0,0.7); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; color: rgba(255,255,255,0.5); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.1s; } - - .img-edit-btn:hover { color: rgba(255,255,255,0.9); border-color: rgba(255,255,255,0.2); } - - .vehicle-lightbox { position: relative; padding-top: 32px; } - - .lightbox-close-btn { position: absolute; top: 0; right: 0; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.12); border-radius: 4px; color: rgba(255,255,255,0.6); cursor: pointer; padding: 4px; display: flex; align-items: center; justify-content: center; transition: all 0.1s; } - - .lightbox-close-btn:hover { background: rgba(255,255,255,0.2); color: #fff; } - - .vehicle-lightbox-img { max-width: 90vw; max-height: calc(90vh - 32px); object-fit: contain; display: block; border-radius: 4px; } - + /* ===== Modal ===== */ + .img-modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + backdrop-filter: blur(2px); + } + + .img-modal { + background: var(--dark-bg); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + width: min(360px, 92vw); + display: flex; + flex-direction: column; + } + + .img-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + } + + .img-modal-close { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 3px; + color: rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; + } + + .img-modal-close:hover { + color: rgba(255, 255, 255, 0.7); + border-color: rgba(255, 255, 255, 0.1); + } + + .img-modal-body { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 6px; + } + + .img-modal-label { + color: rgba(255, 255, 255, 0.35); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + } + + .img-modal-input { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 3px; + padding: 5px 8px; + color: rgba(255, 255, 255, 0.8); + font-size: 11px; + font-family: inherit; + width: 100%; + } + + .img-modal-input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.1); + } + + .img-modal-input::placeholder { + color: rgba(255, 255, 255, 0.2); + } + + .img-modal-hint { + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; + color: rgba(255, 255, 255, 0.25); + line-height: 1.4; + } + + .img-modal-hint a { + color: rgba(var(--accent-text-rgb), 0.5); + text-decoration: none; + transition: color 0.1s; + } + + .img-modal-hint a:hover { + color: rgba(var(--accent-text-rgb), 0.85); + text-decoration: underline; + } + + .img-modal-footer { + display: flex; + justify-content: flex-end; + gap: 6px; + padding: 10px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + } + + .img-modal-cancel { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 3px; + padding: 4px 10px; + color: rgba(255, 255, 255, 0.4); + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: all 0.1s; + } + + .img-modal-cancel:hover:not(:disabled) { + color: rgba(255, 255, 255, 0.7); + border-color: rgba(255, 255, 255, 0.1); + } + + .img-modal-cancel:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .img-modal-confirm { + background: rgba(16, 185, 129, 0.06); + color: rgba(52, 211, 153, 0.7); + border: 1px solid rgba(16, 185, 129, 0.1); + border-radius: 3px; + padding: 4px 12px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: all 0.1s; + } + + .img-modal-confirm:hover:not(:disabled) { + background: rgba(16, 185, 129, 0.12); + color: rgba(110, 231, 183, 0.9); + } + + .img-modal-confirm:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + /* ===== Image Edit / Lightbox ===== */ + .img-edit-btn { + position: absolute; + bottom: -1px; + right: -1px; + width: 22px; + height: 22px; + background: rgba(0, 0, 0, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; + } + + .img-edit-btn:hover { + color: rgba(255, 255, 255, 0.9); + border-color: rgba(255, 255, 255, 0.2); + } + + .vehicle-lightbox { + position: relative; + padding-top: 32px; + } + + .lightbox-close-btn { + position: absolute; + top: 0; + right: 0; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; + } + + .lightbox-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; + } + + .vehicle-lightbox-img { + max-width: 90vw; + max-height: calc(90vh - 32px); + object-fit: contain; + display: block; + border-radius: 4px; + } + + /* ===== Vehicle Notes ===== */ + .section-title { + display: flex; + align-items: center; + gap: 6px; + color: rgba(255, 255, 255, 0.35); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + margin-bottom: 8px; + } + + .notes-edit-btn { + display: flex; + align-items: center; + gap: 3px; + margin-left: auto; + background: rgba(59, 130, 246, 0.06); + border: 1px solid rgba(59, 130, 246, 0.1); + border-radius: 3px; + padding: 2px 8px; + color: rgba(147, 197, 253, 0.7); + font-size: 9px; + font-weight: 600; + cursor: pointer; + transition: all 0.12s; + text-transform: none; + letter-spacing: 0; + } + + .notes-edit-btn:hover { + background: rgba(59, 130, 246, 0.12); + color: rgba(147, 197, 253, 0.9); + } + + .notes-textarea { + width: 100%; + min-height: 80px; + max-height: 300px; + resize: vertical; + overflow-y: auto; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 3px; + padding: 6px 8px; + color: rgba(255, 255, 255, 0.8); + font-size: 11px; + font-family: inherit; + line-height: 1.5; + outline: none; + box-sizing: border-box; + transition: border-color 0.1s; + } + + .notes-textarea:focus { + border-color: rgba(96, 165, 250, 0.3); + } + + .notes-textarea::placeholder { + color: rgba(255, 255, 255, 0.2); + } + + .notes-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + } + + .notes-save-btn { + background: rgba(16, 185, 129, 0.08); + border: 1px solid rgba(16, 185, 129, 0.15); + color: rgba(52, 211, 153, 0.8); + padding: 4px 12px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: all 0.12s; + } + + .notes-save-btn:hover:not(:disabled) { + background: rgba(16, 185, 129, 0.14); + color: #34d399; + } + + .notes-save-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .notes-cancel-btn { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.35); + padding: 4px 10px; + border-radius: 3px; + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: all 0.12s; + } + + .notes-cancel-btn:hover { + color: rgba(255, 255, 255, 0.6); + border-color: rgba(255, 255, 255, 0.1); + } + + .notes-char-count { + font-size: 10px; + color: rgba(255, 255, 255, 0.2); + } + + .notes-char-warn { + color: #f87171; + } + \ No newline at end of file From 7324229dd199b799f43b01f96f17f417b4a4f0bf Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 10 May 2026 13:57:58 +0200 Subject: [PATCH 058/144] Add files via upload --- server/backend/vehicles.lua | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/server/backend/vehicles.lua b/server/backend/vehicles.lua index c25dc2a8..162c8b0d 100644 --- a/server/backend/vehicles.lua +++ b/server/backend/vehicles.lua @@ -31,6 +31,16 @@ local function formatLabel(value) return formatted end +local function parseStatus(raw) + if not raw or raw == '' then return 'valid', '' end + local ok, decoded = pcall(function() return json.decode(raw) end) + if ok and type(decoded) == 'table' then + return decoded.status or 'valid', decoded.reason or '' + end + -- Fallback für alte plain-text Werte + return raw, '' +end + local function getVehicleShared(model) if not Core or not Core.Shared or not Core.Shared.Vehicles then return nil @@ -118,7 +128,8 @@ ps.registerCallback(resourceName .. ':server:GetVehicles', function(source) local plate = v.plate and string.upper(v.plate) or 'UNKNOWN' local reportCount = countSetItems(reportIdsByPlate[plate]) local hasActiveBolo = activeBoloByPlate[plate] == true or v.boloactive == 1 - local flags = buildVehicleFlags(v.stolen == 1, hasActiveBolo, v.status) + local statusName, statusReason = parseStatus(v.status) + local flags = buildVehicleFlags(v.stolen == 1, hasActiveBolo, statusName) table.insert(vehicles, { id = v.id, @@ -132,7 +143,8 @@ ps.registerCallback(resourceName .. ':server:GetVehicles', function(source) image = (v.image and v.image ~= '' and v.image) or ('https://docs.fivem.net/vehicles/' .. v.vehicle .. '.webp'), seenIn = reportCount, points = tonumber(v.points) or 0, - status = v.status or 'valid', + status = statusName, + reason = statusReason, core_state = tonumber(v.core_state) or 0, }) end @@ -204,8 +216,10 @@ ps.registerCallback(resourceName .. ':server:UpdateVehicle', function(source, pa end if status ~= nil then + local reason = payload.reason or '' + local encoded = json.encode({ status = status, reason = reason }) updates[#updates + 1] = 'mdt_vehicle_status = ?' - values[#values + 1] = status + values[#values + 1] = encoded end if #updates == 0 then @@ -283,7 +297,8 @@ ps.registerCallback(resourceName .. ':server:GetVehicle', function(source, plate end local reportCount = countSetItems(reportIdSet) - local flags = buildVehicleFlags(row.stolen == 1, hasActiveBolo or row.boloactive == 1, row.status) + local statusName, statusReason = parseStatus(row.status) + local flags = buildVehicleFlags(row.stolen == 1, hasActiveBolo or row.boloactive == 1, statusName) return { success = true, @@ -299,7 +314,8 @@ ps.registerCallback(resourceName .. ':server:GetVehicle', function(source, plate image = (row.image and row.image ~= '' and row.image) or ('https://docs.fivem.net/vehicles/' .. row.vehicle .. '.webp'), information = row.information or '', points = tonumber(row.points) or 0, - status = row.status or 'valid', + status = statusName, + reason = statusReason, core_state = tonumber(row.core_state) or 0, stolen = row.stolen == 1, boloactive = row.boloactive == 1, From f0322534cc4cbfec7171ff86689cf3020b31e92c Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 10 May 2026 15:09:43 +0200 Subject: [PATCH 059/144] fixed offset fixed offset --- web/src/pages/Map.svelte | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Map.svelte b/web/src/pages/Map.svelte index c7fc7249..1a151d1c 100644 --- a/web/src/pages/Map.svelte +++ b/web/src/pages/Map.svelte @@ -26,10 +26,19 @@ let vehicleLayer = L.layerGroup(); let bodycamLayer = L.layerGroup(); - const coordScale = 4.5; + // bigger = right + // smaller = left + const offsetX = 13; + + // bigger = down + // smaller = up + const offsetY = 5; function toMapLatLng(coords: { x: number; y: number }) { - return [coords.y / coordScale, coords.x / coordScale] as [number, number]; + return [ + coords.y - offsetY, + coords.x + offsetX + ]; } function getTrackConfig(kind: "officer" | "vehicle" | "bodycam") { From 2c1459e49e50c6f74d51c02b42269fc8cece02f6 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 17 May 2026 13:42:49 +0200 Subject: [PATCH 060/144] added new svelte ( fixed evidence tab and case tab ) added new svelte ( fixed evidence tab and case tab ) --- web/src/pages/Cases.svelte | 10 - web/src/pages/Evidence.svelte | 997 +++++++++++++--------------------- 2 files changed, 364 insertions(+), 643 deletions(-) diff --git a/web/src/pages/Cases.svelte b/web/src/pages/Cases.svelte index 1c1c0bec..2aa6d723 100644 --- a/web/src/pages/Cases.svelte +++ b/web/src/pages/Cases.svelte @@ -831,16 +831,6 @@
-
- { - const input = event.target as HTMLInputElement; - attachmentFile = input.files && input.files[0] ? input.files[0] : null; - }} /> - {#if attachmentFile} - {attachmentFile.name} ({formatBytes(attachmentFile.size)}) - {/if} - -
{#if attachmentError}

{attachmentError}

{/if} diff --git a/web/src/pages/Evidence.svelte b/web/src/pages/Evidence.svelte index cb91f1e7..ae75c808 100644 --- a/web/src/pages/Evidence.svelte +++ b/web/src/pages/Evidence.svelte @@ -1,7 +1,6 @@ + +{#if sidebarImageUrlModalOpen} + + +{/if} + + +{#if createImageUrlModalOpen} + + +{/if} +
@@ -414,15 +528,12 @@ }} />
- + - + + +
- - + +
Custody Log
{#if custodyEntries.length === 0} @@ -561,61 +658,26 @@
- +
- +
-
Upload Evidence Image
-
- { - const input = event.target as HTMLInputElement; - evidenceImageFile = input.files && input.files[0] ? input.files[0] : null; - }} - /> - - -
- {#if isUploading} -

Uploading image, please wait...

- {:else if evidenceImageFile} -

- {evidenceImageFile.name} ({formatBytes(evidenceImageFile.size)}) -

- {/if} +
Evidence Images
+ {#if uploadSuccess}

{uploadSuccess}

{/if} -
- - - {#if selectedEvidence?.stash_id} -
-
Evidence Stash
-
- {selectedEvidence.stash_id} - -
-
- {/if} - - - {#if selectedEvidence?.images && selectedEvidence.images.length > 0} -
-
Evidence Images
- + + + {#if selectedEvidence?.stash_id} +
+
Evidence Stash
+
+ {selectedEvidence.stash_id} + +
{/if} {:else} @@ -642,6 +718,7 @@
+ {#if showCreate} - +
{#if typeConfig.description}
@@ -710,7 +787,7 @@ {/if}
- +
Location @@ -730,37 +807,12 @@ Evidence is stored / secured {/if} +
Notes
-
- Attach Images - { - const input = event.target as HTMLInputElement; - if (input.files) { - createImageFiles = [...createImageFiles, ...Array.from(input.files)]; - } - }} - /> - {#if createImageFiles.length > 0} -
- {#each createImageFiles as file, i} -
- {file.name} ({formatBytes(file.size)}) - -
- {/each} -
- {/if} -
+
{#if evidenceError}

{evidenceError}

@@ -773,6 +825,7 @@
{/if} + {#if lightboxUrl} + + {#if canPin()} +
+ + +
+ {/if} +
+ + + + +{/if} + + +{#if deleteConfirm.open} + +{/if} + + \ No newline at end of file From 267793c69032d4521fef29f747e55ce603122bb3 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:09:57 +0200 Subject: [PATCH 079/144] Update config.lua --- config.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config.lua b/config.lua index 1850829e..31fe01c1 100644 --- a/config.lua +++ b/config.lua @@ -257,6 +257,10 @@ Config.ManagementPermissions = { -- FTO 'fto_view', 'fto_manage', + -- BulletIn Board + 'bulletin_view', + 'bulletin_post', + 'bulletin_pin', -- Management 'management_permissions', 'management_bulletins', @@ -343,4 +347,4 @@ Config.Weapons = { { model = "weapon_doubleaction", label = "Double-Action Revolver" }, { model = "weapon_navyrevolver", label = "Navy Revolver" }, { model = "weapon_musket", label = "Musket" }, -} \ No newline at end of file +} From 68ae8337aa2ed7aabc96d0e3914ef3315912a891 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:10:24 +0200 Subject: [PATCH 080/144] added new bulletinboard added new bulletinboard --- server/backend/bulletinboard.lua | 218 +++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 server/backend/bulletinboard.lua diff --git a/server/backend/bulletinboard.lua b/server/backend/bulletinboard.lua new file mode 100644 index 00000000..13f26998 --- /dev/null +++ b/server/backend/bulletinboard.lua @@ -0,0 +1,218 @@ +local resourceName = tostring(GetCurrentResourceName()) + +-- ── Get all bulletin posts for the officer's department ────── + +ps.registerCallback(resourceName .. ':server:getBulletinPosts', function(source) + local src = source + if not CheckAuth(src) then return {} end + + local jobName = ps.getJobName(src) + if not jobName or jobName == '' then return {} end + + local ok, posts = pcall(MySQL.query.await, [[ + SELECT + id, title, content, author, author_rank, + category, priority, pinned, created_by, + created_at, updated_at + FROM mdt_bulletin_posts + WHERE job = ? + ORDER BY pinned DESC, FIELD(priority, 'urgent', 'high', 'normal', 'low'), created_at DESC + ]], { jobName }) + + if not ok or not posts then return {} end + + -- Convert tinyint pinned → boolean for JSON + for _, post in ipairs(posts) do + post.pinned = post.pinned == 1 or post.pinned == "1" or post.pinned == true + end + + return posts +end) + +-- ── Create a bulletin post ──────────────────────────────────── + +ps.registerCallback(resourceName .. ':server:createBulletinPost', function(source, data) + local src = source + if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end + if not CheckPermission(src, 'bulletin_post') then + return { success = false, error = 'No permission to create bulletin posts' } + end + + data = data or {} + + local VALID_CATEGORIES = { announcement = true, operations = true, training = true, general = true, warrants = true } + local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true } + + local title = tostring(data.title or ''):gsub('^%s+', ''):gsub('%s+$', '') + if title == '' then return { success = false, error = 'Title is required' } end + if not VALID_CATEGORIES[data.category] then return { success = false, error = 'Invalid category' } end + if not VALID_PRIORITIES[data.priority] then return { success = false, error = 'Invalid priority' } end + + local jobName = ps.getJobName(src) + local jobRank = ps.getJobGradeName(src) + local citizenId = ps.getIdentifier(src) + + -- Resolve author name + rank from profile + local profile = MySQL.single.await('SELECT fullname FROM mdt_profiles WHERE citizenid = ?', { citizenId }) + local author = (profile and profile.fullname) or tostring(GetPlayerName(src) or 'Unknown') + + -- Only supervisors may pin + local canPin = CheckPermission(src, 'bulletin_pin') + local pinned = (canPin and data.pinned == true) and 1 or 0 + + local id = MySQL.insert.await([[ + INSERT INTO mdt_bulletin_posts + (title, content, author, author_rank, category, priority, pinned, job, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + title:sub(1, 255), + tostring(data.content or ''):sub(1, 65535), + author:sub(1, 100), + jobRank, + data.category, + data.priority, + pinned, + jobName, + citizenId + }) + + if not id then return { success = false, error = 'Database error' } end + return { success = true, id = id } +end) + +-- ── Update a bulletin post ──────────────────────────────────── + +ps.registerCallback(resourceName .. ':server:updateBulletinPost', function(source, postId, updates) + local src = source + if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end + + postId = tonumber(postId) + updates = updates or {} + if not postId then return { success = false, error = 'Invalid post id' } end + + -- Fetch post to verify ownership / permission + local existing = MySQL.single.await( + 'SELECT created_by, job FROM mdt_bulletin_posts WHERE id = ?', + { postId } + ) + if not existing then return { success = false, error = 'Post not found' } end + + local jobName = ps.getJobName(src) + local citizenId = ps.getIdentifier(src) + local isSupervisor = CheckPermission(src, 'bulletin_post') + local isOwner = existing.created_by == citizenId + + -- Authors can edit their own posts; supervisors can edit any post in the same department + if not isOwner and not isSupervisor then + return { success = false, error = 'No permission to edit this post' } + end + if existing.job ~= jobName then + return { success = false, error = 'Post belongs to a different department' } + end + + local VALID_CATEGORIES = { announcement = true, operations = true, training = true, general = true, warrants = true } + local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true } + + local sets = {} + local vals = {} + + if updates.title ~= nil then + local t = tostring(updates.title):gsub('^%s+', ''):gsub('%s+$', '') + if t ~= '' then + sets[#sets + 1] = 'title = ?' + vals[#vals + 1] = t:sub(1, 255) + end + end + if updates.content ~= nil then + sets[#sets + 1] = 'content = ?' + vals[#vals + 1] = tostring(updates.content):sub(1, 65535) + end + if updates.category ~= nil and VALID_CATEGORIES[updates.category] then + sets[#sets + 1] = 'category = ?' + vals[#vals + 1] = updates.category + end + if updates.priority ~= nil and VALID_PRIORITIES[updates.priority] then + sets[#sets + 1] = 'priority = ?' + vals[#vals + 1] = updates.priority + end + -- Only supervisors may change pin state + if updates.pinned ~= nil and isSupervisor then + sets[#sets + 1] = 'pinned = ?' + vals[#vals + 1] = updates.pinned and 1 or 0 + end + + if #sets == 0 then return { success = false, error = 'No valid fields to update' } end + + vals[#vals + 1] = postId + MySQL.update.await('UPDATE mdt_bulletin_posts SET ' .. table.concat(sets, ', ') .. ' WHERE id = ?', vals) + return { success = true } +end) + +-- ── Delete a bulletin post (soft delete) ───────────────────── + +ps.registerCallback(resourceName .. ':server:deleteBulletinPost', function(source, postId) + local src = source + if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end + + postId = tonumber(postId) + if not postId then return { success = false, error = 'Invalid post id' } end + + local existing = MySQL.single.await( + 'SELECT created_by, job FROM mdt_bulletin_posts WHERE id = ?', + { postId } + ) + if not existing then return { success = false, error = 'Post not found' } end + + local jobName = ps.getJobName(src) + local citizenId = ps.getIdentifier(src) + local isSupervisor = CheckPermission(src, 'bulletin_pin') + local isOwner = existing.created_by == citizenId + + if not isOwner and not isSupervisor then + return { success = false, error = 'No permission to delete this post' } + end + if existing.job ~= jobName then + return { success = false, error = 'Post belongs to a different department' } + end + + MySQL.update.await('DELETE FROM mdt_bulletin_posts WHERE id = ?', { postId }) + return { success = true } +end) + +-- ── Toggle pin on a bulletin post ──────────────────────────── + +ps.registerCallback(resourceName .. ':server:toggleBulletinPin', function(source, postId) + local src = source + local newPinned = 0 + if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end + if not CheckPermission(src, 'bulletin_pin') then + return { success = false, error = 'No permission to pin posts' } + end + + postId = tonumber(postId) + if not postId then return { success = false, error = 'Invalid post id' } end + + local existing = MySQL.single.await( + 'SELECT pinned, job FROM mdt_bulletin_posts WHERE id = ?', + { postId } + ) + if not existing then return { success = false, error = 'Post not found' } end + + local jobName = ps.getJobName(src) + if existing.job ~= jobName then + return { success = false, error = 'Post belongs to a different department' } + end + + local isPinned = existing.pinned == 1 or existing.pinned == "1" or existing.pinned == true + + if not isPinned then + newPinned = 1 + end + + MySQL.update.await( + 'UPDATE mdt_bulletin_posts SET pinned = ? WHERE id = ?', + { newPinned, postId } + ) + + return { success = true, pinned = newPinned } +end) \ No newline at end of file From 711f9c99120bc73641977d4b2872b3f407b7dc92 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:10:45 +0200 Subject: [PATCH 081/144] added new bulletinboard - client added new bulletinboard --- client/backend/bulletinboard.lua | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 client/backend/bulletinboard.lua diff --git a/client/backend/bulletinboard.lua b/client/backend/bulletinboard.lua new file mode 100644 index 00000000..d6729213 --- /dev/null +++ b/client/backend/bulletinboard.lua @@ -0,0 +1,37 @@ +local resourceName = tostring(GetCurrentResourceName()) + +-- Get all bulletin posts for the officer's department +RegisterNUICallback('getBulletinPosts', function(data, cb) + local result = ps.callback(resourceName .. ':server:getBulletinPosts') + cb(result or {}) +end) + +-- Create a new bulletin post +RegisterNUICallback('createBulletinPost', function(data, cb) + if not data then cb({ success = false }) return end + local result = ps.callback(resourceName .. ':server:createBulletinPost', data) + cb(result or { success = false }) +end) + +-- Update an existing bulletin post +RegisterNUICallback('updateBulletinPost', function(data, cb) + if not data or not data.id then cb({ success = false }) return end + local postId = data.id + data.id = nil + local result = ps.callback(resourceName .. ':server:updateBulletinPost', postId, data) + cb(result or { success = false }) +end) + +-- Delete a bulletin post (soft delete) +RegisterNUICallback('deleteBulletinPost', function(data, cb) + if not data or not data.id then cb({ success = false }) return end + local result = ps.callback(resourceName .. ':server:deleteBulletinPost', data.id) + cb(result or { success = false }) +end) + +-- Toggle pin on a bulletin post (supervisor only) +RegisterNUICallback('toggleBulletinPin', function(data, cb) + if not data or not data.id then cb({ success = false }) return end + local result = ps.callback(resourceName .. ':server:toggleBulletinPin', data.id) + cb(result or { success = false }) +end) \ No newline at end of file From e9937f04c2ed59022dd728080cb86941746ffaea Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:11:23 +0200 Subject: [PATCH 082/144] changes to bulletin management changes to bulletin management --- .../management/ManagementBulletins.svelte | 489 +++++++++++++++--- 1 file changed, 411 insertions(+), 78 deletions(-) diff --git a/web/src/components/management/ManagementBulletins.svelte b/web/src/components/management/ManagementBulletins.svelte index 5ed21d69..18c7994b 100644 --- a/web/src/components/management/ManagementBulletins.svelte +++ b/web/src/components/management/ManagementBulletins.svelte @@ -4,11 +4,21 @@ import { isEnvBrowser } from "../../utils/misc"; import { NUI_EVENTS } from "../../constants/nuiEvents"; + // ── Types ────────────────────────────────────────────────── + interface Bulletin { id?: number; content: string; } + interface BulletinCategory { + value: string; + label: string; + icon: string; + } + + // ── MotD state ───────────────────────────────────────────── + let bulletins: Bulletin[] = $state([]); let newTitle: string = $state(""); let newContent: string = $state(""); @@ -16,11 +26,28 @@ let isSubmitting = $state(false); let statusMessage: { text: string; type: "success" | "error" } | null = $state(null); + // ── Category state ───────────────────────────────────────── + + const DEFAULT_CATEGORIES: BulletinCategory[] = [ + { value: 'announcement', label: 'Announcements', icon: 'campaign' }, + { value: 'operations', label: 'Operations', icon: 'local_police' }, + { value: 'training', label: 'Training', icon: 'school' }, + { value: 'general', label: 'General', icon: 'forum' }, + ]; + + let categories = $state(DEFAULT_CATEGORIES.map(c => ({ ...c }))); + let categoriesLoading = $state(false); + let categoriesSaving = $state(false); + + // ── Shared helpers ───────────────────────────────────────── + function showStatus(text: string, type: "success" | "error" = "success") { statusMessage = { text, type }; setTimeout(() => { statusMessage = null; }, 3000); } + // ── MotD logic ───────────────────────────────────────────── + async function loadBulletins() { if (isEnvBrowser()) return; try { @@ -115,6 +142,58 @@ } } + // ── Category logic ───────────────────────────────────────── + + async function loadCategories() { + if (isEnvBrowser()) return; + try { + categoriesLoading = true; + const result = await fetchNui( + NUI_EVENTS.BULLETIN.GET_CATEGORIES, + {}, + [], + ); + if (Array.isArray(result) && result.length > 0) { + categories = result; + } + } catch (error) { + console.error("Failed to load categories:", error); + } finally { + categoriesLoading = false; + } + } + + async function saveCategories() { + if (isEnvBrowser()) { + showStatus("Categories saved"); + return; + } + try { + categoriesSaving = true; + const result = await fetchNui<{ success: boolean; message?: string }>( + NUI_EVENTS.BULLETIN.SAVE_CATEGORIES, + { categories }, + { success: false }, + ); + if (result?.success) { + showStatus("Categories saved"); + } else { + showStatus(result?.message || "Failed to save categories", "error"); + } + } catch (error) { + console.error("Failed to save categories:", error); + showStatus("Failed to save categories", "error"); + } finally { + categoriesSaving = false; + } + } + + function resetCategories() { + categories = DEFAULT_CATEGORIES.map(c => ({ ...c })); + } + + // ── Mount ────────────────────────────────────────────────── + onMount(() => { if (isEnvBrowser()) { bulletins = [ @@ -125,84 +204,165 @@ return; } loadBulletins(); + loadCategories(); }); -
+
{#if statusMessage}
{statusMessage.text}
{/if} -
-
- - + +
+
+ Message of the Day
- -
- {#if isLoading} -
-
-

Loading bulletins...

+
+
+ + +
+
- {:else} -
- {#each bulletins as bulletin (bulletin.id || bulletin.content)} - {@const parsed = parseBulletin(bulletin.content)} -
-
- {#if parsed.title && parsed.body} - {parsed.title} -

{parsed.body}

- {:else} -

{bulletin.content}

+ + {#if isLoading} +
+
+

Loading bulletins...

+
+ {:else} +
+ {#each bulletins as bulletin (bulletin.id || bulletin.content)} + {@const parsed = parseBulletin(bulletin.content)} +
+
+ {#if parsed.title && parsed.body} + {parsed.title} +

{parsed.body}

+ {:else} +

{bulletin.content}

+ {/if} +
+ {#if bulletin.id} + {/if}
- {#if bulletin.id} - - {/if} -
- {:else} -
No bulletins posted.
- {/each} + {:else} +
No bulletins posted.
+ {/each} +
+ {/if} +
+ + +
+
+ Bulletin Board Categories +
+ + +
- {/if} + +

+ Customize the category labels and icons shown in the Bulletin Board sidebar. + Use any Material Icon name. +

+ + {#if categoriesLoading} +
+
+

Loading categories...

+
+ {:else} +
+ {#each categories as cat} +
+ +
+ {cat.icon || 'help_outline'} +
+ + +
+ Label + +
+ + +
+ Icon name + +
+ + +
{cat.value}
+
+ {/each} +
+ {/if} +
+ \ No newline at end of file From edab298354dfdc05a0fa171220fccf42e0132b02 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:12:01 +0200 Subject: [PATCH 083/144] changes to a new tab changes to a new tab --- web/src/components/ContentArea.svelte | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/src/components/ContentArea.svelte b/web/src/components/ContentArea.svelte index 2811e09d..cd5c3fbe 100644 --- a/web/src/components/ContentArea.svelte +++ b/web/src/components/ContentArea.svelte @@ -35,6 +35,7 @@ import LegalDocuments from "../pages/doj/LegalDocuments.svelte"; import type { createInstanceStateService } from "../services/instanceStateService.svelte"; import type { createTabService } from "../services/tabService.svelte"; + import BulletInBoard from "@/pages/BulletInBoard.svelte"; interface Props { authService: AuthService; @@ -119,6 +120,7 @@ bodycams: ["bodycams_view"], ia: ["ia_view"], sop: ["sop_view", "sop_manage"], + bulletin_board: ["bulletin_view"], management: ["management_settings", "management_bulletins", "management_activity", "management_permissions", "management_tracking"], settings: ["management_settings"], }; @@ -147,6 +149,8 @@ cameras: "Cameras", bodycams: "Bodycams", management: "Settings", + sop: "SOP", + bulletin_board: "Bulletin Board", settings: "Preferences", }; return labels[pageId] || pageId; @@ -225,6 +229,8 @@ {:else if activeComponent === "sop"} + {:else if activeComponent === "bulletin_board"} + {:else if activeComponent === "management"} {:else if activeComponent === "settings"} From 755800ecb4417952dd0304828ba76077c435acfd Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:12:34 +0200 Subject: [PATCH 084/144] Add files via upload --- web/src/constants/index.ts | 10 ++++++++-- web/src/constants/management.ts | 11 +++++++++++ web/src/constants/nuiEvents.ts | 10 ++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index 5021a5ea..5ce92831 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -19,6 +19,7 @@ export const MDT_TABS = [ { name: "PPR", icon: "rate_review" }, { name: "FTO", icon: "school" }, { name: "SOP", icon: "menu_book" }, + { name: "Bulletin Board", icon: "forum" }, { name: "Court Cases", icon: "gavel" }, { name: "Warrant Review", icon: "policy" }, { name: "Court Orders", icon: "assignment_late" }, @@ -33,6 +34,7 @@ export const EMS_TABS: readonly (typeof MDT_TABS)[number]["name"][] = [ "Citizens", "Reports", "Roster", + "Bulletin Board", "Map", "Bodycams", "Preferences", @@ -49,6 +51,7 @@ export const DOJ_TABS: readonly string[] = [ "Cases", "Evidence", "Court Orders", + "Bulletin Board", "Legal Documents", "Charges", "Settings", @@ -73,7 +76,7 @@ export interface NavGroup { } export const NAV_GROUPS: NavGroup[] = [ - { id: "dashboard", tabs: ["Dashboard"] }, + { id: "dashboard", tabs: ["Dashboard", "Bulletin Board"] }, { id: "operations", label: "Operations", icon: "assignment", tabs: ["Reports", "Cases", "Evidence", "BOLOs", "Warrants"] }, { id: "records", label: "Records", icon: "folder_open", tabs: ["Citizens", "Vehicles", "Weapons", "Charges"] }, { id: "personnel", label: "Personnel", icon: "badge", tabs: ["Roster", "Awards", "IA", "PPR", "FTO", "SOP"] }, @@ -143,6 +146,7 @@ export type ComponentId = | "ppr" | "fto" | "sop" + | "bulletin_board" | "court_cases" | "warrant_review" | "court_orders" @@ -171,6 +175,7 @@ export const TAB_TO_COMPONENT_MAP: Record = { PPR: "ppr", FTO: "fto", SOP: "sop", + "Bulletin Board": "bulletin_board", "Court Cases": "court_cases", "Warrant Review": "warrant_review", "Court Orders": "court_orders", @@ -185,7 +190,7 @@ export const DEFAULT_DATE = "03.15.2024"; /** App version and branding per job type */ export const APP_INFO = { leo: { - version: "LSPD MDT System v2.0", + version: "LSPD MDT System v3.0", title: "Los Santos Police Department", subtitle: "Mobile Data Terminal", footerSubtext: "Authorized Personnel Only", @@ -247,6 +252,7 @@ export const COMPONENT_DISPLAY_NAMES: Record = { ppr: "Performance Reviews", fto: "Field Training", sop: "Standard Operating Procedures", + bulletin_board: "Bulletin Board", court_cases: "Court Cases", warrant_review: "Warrant Review", court_orders: "Court Orders", diff --git a/web/src/constants/management.ts b/web/src/constants/management.ts index 21d6db63..215589c8 100644 --- a/web/src/constants/management.ts +++ b/web/src/constants/management.ts @@ -162,6 +162,16 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [ { key: "sop_manage", label: "Manage SOP", description: "Create, edit, and publish standard operating procedures" }, ], }, + { + key: "bulletin_board", + label: "Bulletin Board ( Creator or Manage Posts can Delete )", + icon: "forum", + permissions: [ + { key: "bulletin_view", label: "View BP", description: "View Bulletin Posts" }, + { key: "bulletin_post", label: "Manage Posts", description: "Create and Edit Bulletin Posts" }, + { key: "bulletin_pin", label: "Pin Posts", description: "Pin Bulletin Posts" }, + ], + }, { key: "management", label: "Management", @@ -196,6 +206,7 @@ export const TAB_VISIBILITY_KEYS = [ { tabName: "PPR", key: "tab_hidden_ppr", label: "PPR" }, { tabName: "FTO", key: "tab_hidden_fto", label: "Field Training" }, { tabName: "SOP", key: "tab_hidden_sop", label: "SOP" }, + { tabName: "Bulletin Board", key: "tab_hidden_bulletin_board", label: "Bulletin BoardOP" }, ]; /** Flat list of all permission keys */ diff --git a/web/src/constants/nuiEvents.ts b/web/src/constants/nuiEvents.ts index e085a28b..bbd10146 100644 --- a/web/src/constants/nuiEvents.ts +++ b/web/src/constants/nuiEvents.ts @@ -293,6 +293,15 @@ export const NUI_EVENTS = { CHECK_SOP_AGREEMENT: "checkSOPAgreement", ACKNOWLEDGE_SOP: "acknowledgesSOP", }, + BULLETIN: { + GET_POSTS: "getBulletinPosts", + CREATE_POST: "createBulletinPost", + UPDATE_POST: "updateBulletinPost", + DELETE_POST: "deleteBulletinPost", + TOGGLE_PIN: "toggleBulletinPin", + GET_CATEGORIES: 'getBulletinCategories', + SAVE_CATEGORIES: 'saveBulletinCategories', + }, } as const; // Backwards compatibility exports (deprecated - use NUI_EVENTS instead) @@ -338,6 +347,7 @@ export const ALL_NUI_EVENTS = [ ...Object.values(NUI_EVENTS.COLLAB), ...Object.values(NUI_EVENTS.DOJ), ...Object.values(NUI_EVENTS.SOP), + ...Object.values(NUI_EVENTS.BULLETIN), "copyToClipboard", "submitComplaint", "closeComplaint", From aa15ae8d7f104360df8f68b79e061136ac9fe05a Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:12:59 +0200 Subject: [PATCH 085/144] Add files via upload --- web/src/config/security.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/config/security.ts b/web/src/config/security.ts index 391ccf29..57d02303 100644 --- a/web/src/config/security.ts +++ b/web/src/config/security.ts @@ -47,6 +47,7 @@ export const SECURITY_CONFIG = { "PPR", "FTO", "SOP", + "Bulletin Board", "Court Cases", "Warrant Review", "Court Orders", From 1aab0345561130d1c47dad5e52456510a2b09000 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:13:20 +0200 Subject: [PATCH 086/144] Add files via upload --- client/backend/dashboard.lua | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/client/backend/dashboard.lua b/client/backend/dashboard.lua index a56db50b..9f18165f 100644 --- a/client/backend/dashboard.lua +++ b/client/backend/dashboard.lua @@ -174,6 +174,32 @@ RegisterNUICallback('deleteBulletin', function(data, cb) cb(result or { success = false }) end) +RegisterNUICallback('getBulletinCategories', function(_, cb) + local result = lib.callback.await('mdt:server:getBulletinCategories', false) + cb(result or {}) +end) + +RegisterNUICallback('saveBulletinCategories', function(data, cb) + if not data or not data.categories then + cb({ success = false, message = 'Invalid data' }) + return + end + + for _, cat in ipairs(data.categories) do + if type(cat.value) ~= 'string' or type(cat.label) ~= 'string' or type(cat.icon) ~= 'string' then + cb({ success = false, message = 'Malformed category entry' }) + return + end + if #cat.label > 32 or #cat.icon > 48 then + cb({ success = false, message = 'Category label or icon name too long' }) + return + end + end + + local result = lib.callback.await('mdt:server:saveBulletinCategories', false, data.categories) + cb(result or { success = false, message = 'Server error' }) +end) + -- RECENT REPORTS ------------------------------------- RegisterNUICallback('getRecentReports', function(data, cb) From febe6c99caf97a51f76c6510dc368de1c0803e3a Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:13:35 +0200 Subject: [PATCH 087/144] Add files via upload --- server/backend/dashboard.lua | 105 +++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/server/backend/dashboard.lua b/server/backend/dashboard.lua index 17a1f6a8..55239584 100644 --- a/server/backend/dashboard.lua +++ b/server/backend/dashboard.lua @@ -1,6 +1,15 @@ local resourceName = tostring(GetCurrentResourceName()) +local DEFAULT_CATEGORIES = { + { value = 'announcement', label = 'Announcements', icon = 'campaign' }, + { value = 'operations', label = 'Operations', icon = 'local_police' }, + { value = 'training', label = 'Training', icon = 'school' }, + { value = 'general', label = 'General', icon = 'forum' }, +} + +local SETTINGS_KEY = 'bulletin_categories' + local function getEffectiveJobType(src) local jobType = ps.getJobType(src) local jobName = ps.getJobName(src) @@ -356,3 +365,99 @@ ps.registerCallback(resourceName .. ':server:getUsageMetrics', function(source) } end) end) + +local function getSetting(key) + local rows = exports.oxmysql:executeSync( + 'SELECT `value` FROM `mdt_bulletin_settings` WHERE `key` = ? LIMIT 1', + { key } + ) + if rows and rows[1] then + return rows[1].value + end + return nil +end + +local function setSetting(key, value) + exports.oxmysql:executeSync( + [[ + INSERT INTO `mdt_bulletin_settings` (`key`, `value`) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `updated_at` = CURRENT_TIMESTAMP + ]], + { key, value } + ) +end + +-- ── CALLBACK: getBulletinCategories ────────────────────────────────────────── + +lib.callback.register('mdt:server:getBulletinCategories', function(source) + local raw = getSetting(SETTINGS_KEY) + + if not raw then + return DEFAULT_CATEGORIES + end + + local ok, decoded = pcall(json.decode, raw) + if not ok or type(decoded) ~= 'table' or #decoded == 0 then + return DEFAULT_CATEGORIES + end + + local sanitized = {} + for _, cat in ipairs(decoded) do + if type(cat.value) == 'string' and type(cat.label) == 'string' and type(cat.icon) == 'string' then + sanitized[#sanitized + 1] = { + value = cat.value, + label = cat.label, + icon = cat.icon, + } + end + end + + if #sanitized == 0 then + return DEFAULT_CATEGORIES + end + + return sanitized +end) + +-- ── CALLBACK: saveBulletinCategories ───────────────────────────────────────── + +lib.callback.register('mdt:server:saveBulletinCategories', function(source, categories) + if type(categories) ~= 'table' or #categories == 0 then + return { success = false, message = 'No categories provided' } + end + + local ALLOWED_VALUES = { announcement = true, operations = true, training = true, general = true } + + local clean = {} + for _, cat in ipairs(categories) do + if type(cat.value) ~= 'string' or not ALLOWED_VALUES[cat.value] then + return { success = false, message = ('Unknown category value: %s'):format(tostring(cat.value)) } + end + if type(cat.label) ~= 'string' or #cat.label == 0 or #cat.label > 32 then + return { success = false, message = 'Invalid label for category: ' .. tostring(cat.value) } + end + if type(cat.icon) ~= 'string' or #cat.icon == 0 or #cat.icon > 48 then + return { success = false, message = 'Invalid icon for category: ' .. tostring(cat.value) } + end + clean[#clean + 1] = { + value = cat.value, + label = cat.label, + icon = cat.icon, + } + end + + local ok, encoded = pcall(json.encode, clean) + if not ok then + return { success = false, message = 'JSON encode error' } + end + + local saveOk, err = pcall(setSetting, SETTINGS_KEY, encoded) + if not saveOk then + ps.warn('^1[MDT] bulletin_settings save error: ' .. tostring(err) .. '^0') + return { success = false, message = 'Database error' } + end + + ps.success(('[MDT] Bulletin categories updated by source %d'):format(source)) + return { success = true } +end) \ No newline at end of file From 2cd331c4f1a3485e31da7d4383f7562a96b2b50f Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 22 May 2026 17:19:01 +0200 Subject: [PATCH 088/144] miss spelling fixed miss spelling fixed --- web/src/constants/management.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/constants/management.ts b/web/src/constants/management.ts index 215589c8..a15dda8d 100644 --- a/web/src/constants/management.ts +++ b/web/src/constants/management.ts @@ -206,7 +206,7 @@ export const TAB_VISIBILITY_KEYS = [ { tabName: "PPR", key: "tab_hidden_ppr", label: "PPR" }, { tabName: "FTO", key: "tab_hidden_fto", label: "Field Training" }, { tabName: "SOP", key: "tab_hidden_sop", label: "SOP" }, - { tabName: "Bulletin Board", key: "tab_hidden_bulletin_board", label: "Bulletin BoardOP" }, + { tabName: "Bulletin Board", key: "tab_hidden_bulletin_board", label: "Bulletin Board" }, ]; /** Flat list of all permission keys */ From 288a1a2070c89bf730c6294be79c8284652f96d2 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 24 May 2026 16:18:10 +0200 Subject: [PATCH 089/144] fixed pages fixed pages --- web/src/pages/Reports.svelte | 78 ++++++++++++------------------------ 1 file changed, 25 insertions(+), 53 deletions(-) diff --git a/web/src/pages/Reports.svelte b/web/src/pages/Reports.svelte index d3a316f6..d40b1b25 100644 --- a/web/src/pages/Reports.svelte +++ b/web/src/pages/Reports.svelte @@ -16,7 +16,6 @@ import type { createTabService } from "../services/tabService.svelte"; import type { MDTTab } from "../constants"; import Pagination from "../components/Pagination.svelte"; - import type { JobType } from "../interfaces/IUser"; interface Props { @@ -35,6 +34,7 @@ tabService.setInstanceTab(activeInstance.id, tab); } } + const reportService = createReportService(); interface Report { @@ -51,7 +51,6 @@ } let reports: Report[] = $state([]); - let filteredReports: Report[] = $state([]); let searchQuery = $state(""); let filterAuthor = $state(""); let filterType = $state(""); @@ -60,13 +59,11 @@ let analytics = $state({ incidents: 0, arrests: 0, warrants: 0 }); let isLoading = $state(false); let currentPage = $state(1); - let reportsPerPage = $state(50); + let reportsPerPage = $state(25); let totalReports = $state(0); - let showEditor = $state(false); let editingReportId: string | null = $state(null); let pendingOpenId = $state(null); - let debouncedSearchQuery = $state(""); let searchDebounceTimer: ReturnType | null = null; $effect(() => { @@ -78,30 +75,14 @@ } }); - // Debounce search input (200ms) - $effect(() => { - const query = searchQuery; + function handleSearchInput(e: Event) { + searchQuery = (e.target as HTMLInputElement).value; if (searchDebounceTimer) clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { - debouncedSearchQuery = query; - }, 200); - }); - - $effect(() => { - if (debouncedSearchQuery.trim() === "") { - filteredReports = reports; - } else { - const query = debouncedSearchQuery.toLowerCase(); - filteredReports = reports.filter( - (report) => - report.title.toLowerCase().includes(query) || - report.reportId.toLowerCase().includes(query) || - report.author.toLowerCase().includes(query) || - report.type.toLowerCase().includes(query) || - (report.tag && report.tag.toLowerCase().includes(query)), - ); - } - }); + currentPage = 1; + loadReports(1); + }, 300); + } onMount(() => { if (isEnvBrowser()) { @@ -115,8 +96,8 @@ { id: '6', title: 'Warrant Execution - Marcus Johnson', reportId: 'RPT-006', author: 'identifier_123', authorplaintext: 'Det. Williams', type: 'Arrest', datecreated: now - 518400000, dateupdated: now - 432000000, tag: 'Warrant' }, { id: '7', title: 'Hit and Run - Del Perro Pier', reportId: 'RPT-007', author: 'identifier_123', authorplaintext: 'Ofc. Smith', type: 'Incident', datecreated: now - 604800000, dateupdated: now - 518400000 }, ]; - filteredReports = reports; analytics = { incidents: 4, arrests: 2, warrants: 1 }; + totalReports = 7; isLoading = false; return; } @@ -152,18 +133,21 @@ try { isLoading = true; const filters = { + search: searchQuery.trim() || undefined, startDate: filterStartDate || undefined, endDate: filterEndDate || undefined, type: filterType || undefined, author: filterAuthor.trim() || undefined, + limit: reportsPerPage, }; const response = await fetchNui( NUI_EVENTS.REPORT.GET_REPORTS, { page, limit: reportsPerPage, filters }, ); - reports = response.reports || []; - totalReports = response.total ?? reports.length; + const data = response?.reports ?? response; + reports = data?.reports || []; + totalReports = data?.total ?? reports.length; currentPage = page; } catch (error) { debugError("Failed to load reports:", error); @@ -263,12 +247,16 @@ /> {:else}
-
@@ -313,11 +301,10 @@ }} disabled={isLoading || !hasActiveFilters()} >Clear - +
-
@@ -338,7 +325,6 @@
-
Title @@ -355,7 +341,7 @@
Loading reports...
- {:else if filteredReports.length === 0} + {:else if reports.length === 0}
@@ -373,7 +359,7 @@
{:else} - {#each filteredReports.slice().reverse() as report} + {#each reports as report} - {/if} - {#if onTakeMugshot} - - {/if} +
{/if} @@ -137,6 +168,39 @@ {/snippet} {/each} + + {#if photoModalOpen} + + + + {/if} From dc476410430d0644af5c7b26b3db9d4cb2385565 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:28:58 +0200 Subject: [PATCH 094/144] Update README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 41113f44..89e39b04 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ THIS CHANGES NEEDS TO BE DONE: ```lua +CREATE TABLE IF NOT EXISTS mdt_patrols ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(64) NOT NULL, + color VARCHAR(7) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + member_ids TEXT NOT NULL DEFAULT '[]' +); + ALTER TABLE mdt_reports_evidence ADD COLUMN title VARCHAR(255) NOT NULL DEFAULT '' AFTER reportid, ADD COLUMN images LONGTEXT NULL DEFAULT NULL AFTER stored; From b8d5c8aa9632257347f6b70321980fd3ec10c4af Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:30:04 +0200 Subject: [PATCH 095/144] Add files via upload --- web/src/constants/management.ts | 3 +++ web/src/constants/nuiEvents.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/web/src/constants/management.ts b/web/src/constants/management.ts index a15dda8d..03927112 100644 --- a/web/src/constants/management.ts +++ b/web/src/constants/management.ts @@ -140,6 +140,9 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [ label: "Dispatch", icon: "cell_tower", permissions: [ + { key: "map_patrols_view", label: "View Patrols", description: "View patrol assignments on the map" }, + { key: "map_patrols_manage", label: "Manage Patrols", description: "Assign or remove officers from patrols" }, + { key: "map_patrols_edit", label: "Edit Patrols", description: "Create, rename, delete and reorder patrols" }, { key: "dispatch_attach", label: "Attach to Calls", description: "Attach or detach from dispatch calls" }, { key: "dispatch_route", label: "Route to Calls", description: "Set GPS route to dispatch calls" }, ], diff --git a/web/src/constants/nuiEvents.ts b/web/src/constants/nuiEvents.ts index bbd10146..d20ba376 100644 --- a/web/src/constants/nuiEvents.ts +++ b/web/src/constants/nuiEvents.ts @@ -211,7 +211,15 @@ export const NUI_EVENTS = { VIEW_BODYCAM: "viewBodycam", }, MAP: { - GET_TRACKING: "getTracking", + GET_TRACKING: "getTracking", + GET_PATROLS: "getPatrols", + CREATE_PATROL: "createPatrol", + DELETE_PATROL: "deletePatrol", + RENAME_PATROL: "renamePatrol", + ASSIGN_OFFICER: "assignOfficer", + REMOVE_FROM_PATROL: "removeFromPatrol", + SAVE_UI_STATE: "saveMapUiState", + REORDER_PATROLS: "reorderPatrols", }, MANAGEMENT: { GET_PERMISSION_ROLES: "getPermissionRoles", From 5b64a221e858d4843d6ea1088850d3e7759a5f08 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:30:43 +0200 Subject: [PATCH 096/144] Update config.lua --- config.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config.lua b/config.lua index 31fe01c1..d8a43d3c 100644 --- a/config.lua +++ b/config.lua @@ -241,6 +241,9 @@ Config.ManagementPermissions = { 'charges_view', 'charges_edit', -- Dispatch + 'map_patrols_view', + "map_patrols_manage", + "map_patrols_edit", 'dispatch_attach', 'dispatch_route', -- Cameras & Bodycams From fa990e8f0455a632727f4c125c8e224a76188ef9 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:31:18 +0200 Subject: [PATCH 097/144] Add files via upload --- server/backend/management.lua | 2 +- server/backend/tracking.lua | 272 ++++++++++++++++++++++++++++------ 2 files changed, 230 insertions(+), 44 deletions(-) diff --git a/server/backend/management.lua b/server/backend/management.lua index 5b3e3511..2b2c0da9 100644 --- a/server/backend/management.lua +++ b/server/backend/management.lua @@ -66,7 +66,7 @@ local function getAllPermissions() 'reports_view', 'reports_create', 'reports_delete', 'warrants_view', 'warrants_issue', 'warrants_close', 'charges_view', 'charges_edit', - 'dispatch_attach', 'dispatch_route', + 'map_patrols_view', 'map_patrols_manage', 'map_patrols_edit', 'dispatch_attach', 'dispatch_route', 'cameras_view', 'bodycams_view', 'roster_manage_certifications', 'roster_manage_officers', 'management_permissions', 'management_bulletins', 'management_activity', diff --git a/server/backend/tracking.lua b/server/backend/tracking.lua index 3b9541b8..f89106ee 100644 --- a/server/backend/tracking.lua +++ b/server/backend/tracking.lua @@ -1,17 +1,73 @@ +-- server_patrols.lua local resourceName = tostring(GetCurrentResourceName()) local vehicleCache = {} local cacheVehicleCooldowns = {} +local patrols = {} -- { [id] = patrol } +local patrolOrder = {} -- { id, id, id, ... } – ordered list + +-- ─── Validierung ────────────────────────────────────────────────────────── + +local function isValidPatrolId(id) + return type(id) == "string" and #id > 0 and #id <= 64 +end +local function isValidColor(color) + return type(color) == "string" and color:match("^#%x%x%x%x%x%x$") +end +local function isValidName(name) + return type(name) == "string" and #name > 0 and #name <= 64 +end +local function isValidCitizenId(cid) + return type(cid) == "string" and #cid > 0 and #cid <= 64 +end + +-- ─── DB ─────────────────────────────────────────────────────────────────── + +local function savePatrol(patrol) + if not patrol then return end + MySQL.insert( + "INSERT INTO mdt_patrols (id, name, color, member_ids, sort_order) VALUES (?, ?, ?, ?, ?) " .. + "ON DUPLICATE KEY UPDATE name = VALUES(name), color = VALUES(color), member_ids = VALUES(member_ids), sort_order = VALUES(sort_order)", + { patrol.id, patrol.name, patrol.color, json.encode(patrol.memberIds), patrol.sortOrder or 0 } + ) +end + +local function deletePatrolFromDB(id) + MySQL.execute("DELETE FROM mdt_patrols WHERE id = ?", { id }) +end + +local function saveOrder() + for i, id in ipairs(patrolOrder) do + if patrols[id] then + patrols[id].sortOrder = i + MySQL.execute("UPDATE mdt_patrols SET sort_order = ? WHERE id = ?", { i, id }) + end + end +end + +-- ─── Broadcast ──────────────────────────────────────────────────────────── + +-- Sends patrols as sorted array instead of map, +-- so all clients see the same order +local function broadcastPatrols(action, citizenid) + local ordered = {} + for _, id in ipairs(patrolOrder) do + if patrols[id] then + ordered[#ordered + 1] = patrols[id] + end + end + TriggerClientEvent(resourceName .. ":client:syncPatrols", -1, ordered, action, citizenid) +end + +-- ─── Tracking ───────────────────────────────────────────────────────────── local function getAllTrackers() local vehicles = {} local bodycams = {} local seenVehicles = {} - local players = {} - if exports['qb-core'] then local QBCore = exports['qb-core']:GetCoreObject() - players = QBCore.Functions.GetQBPlayers() or {} + local players = QBCore.Functions.GetQBPlayers() or {} for _, player in pairs(players) do local data = player.PlayerData @@ -23,22 +79,14 @@ local function getAllTrackers() if not ped or ped == 0 then goto continue end local coords = GetEntityCoords(ped) - local heading = GetEntityHeading(ped) - local coordsTable = { x = coords.x, y = coords.y, z = coords.z } - - local name = data.charinfo.firstname .. ' ' .. data.charinfo.lastname - local callsign = data.metadata and data.metadata.callsign or nil - local rank = data.job.grade and data.job.grade.name or 'Officer' - - local entry = { + bodycams[#bodycams + 1] = { citizenid = data.citizenid, - name = name, - callsign = callsign, - rank = rank, - coords = coordsTable, - heading = heading, + name = data.charinfo.firstname .. ' ' .. data.charinfo.lastname, + callsign = data.metadata and data.metadata.callsign or nil, + rank = data.job.grade and data.job.grade.name or 'Officer', + coords = { x = coords.x, y = coords.y, z = coords.z }, + heading = GetEntityHeading(ped), } - bodycams[#bodycams + 1] = entry local veh = GetVehiclePedIsIn(ped, false) if veh and veh ~= 0 and not seenVehicles[veh] then @@ -53,16 +101,13 @@ local function getAllTrackers() vehicles[#vehicles + 1] = vEntry vehicleCache[plate] = vEntry end - ::continue:: end elseif ps and ps.getAllPlayers then local playerList = ps.getAllPlayers() or {} - for _, playerId in pairs(playerList) do if not (ps.getJobDuty and ps.getJobDuty(playerId)) then goto continue end - local jobName = ps.getJobName and ps.getJobName(playerId) or nil local jobType = ps.getJobType and ps.getJobType(playerId) or nil if not IsPoliceJob(jobName, jobType) then goto continue end @@ -71,20 +116,14 @@ local function getAllTrackers() if not ped or ped == 0 then goto continue end local coords = GetEntityCoords(ped) - local heading = GetEntityHeading(ped) - local coordsTable = { x = coords.x, y = coords.y, z = coords.z } - local name = (ps.getPlayerName and ps.getPlayerName(playerId)) or GetPlayerName(playerId) or 'Unknown' - local callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil - - local entry = { + bodycams[#bodycams + 1] = { citizenid = ps.getIdentifier and ps.getIdentifier(playerId) or nil, - name = name, - callsign = callsign, + name = (ps.getPlayerName and ps.getPlayerName(playerId)) or GetPlayerName(playerId) or 'Unknown', + callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil, rank = ps.getJobGradeName and ps.getJobGradeName(playerId) or 'Officer', - coords = coordsTable, - heading = heading, + coords = { x = coords.x, y = coords.y, z = coords.z }, + heading = GetEntityHeading(ped), } - bodycams[#bodycams + 1] = entry local veh = GetVehiclePedIsIn(ped, false) if veh and veh ~= 0 and not seenVehicles[veh] then @@ -99,11 +138,11 @@ local function getAllTrackers() vehicles[#vehicles + 1] = vEntry vehicleCache[plate] = vEntry end - ::continue:: end end + -- Cached vehicles not currently driven by anyone for plate, cacheData in pairs(vehicleCache) do if not seenVehicles[cacheData._entity] then local alreadyAdded = false @@ -123,17 +162,14 @@ ps.registerCallback(resourceName .. ':server:getTracking', function(source) if not CheckAuth(source) then return { vehicles = {}, bodycams = {} } end - local vehicles, bodycams = getAllTrackers() return { vehicles = vehicles, bodycams = bodycams } end) RegisterNetEvent(resourceName .. ':server:cacheVehicle', function(plate, coords, heading) local src = source - if type(plate) ~= 'string' or #plate == 0 or #plate > 8 then return end - if type(coords) ~= 'table' or type(coords.x) ~= 'number' - or type(coords.y) ~= 'number' or type(coords.z) ~= 'number' then return end + if type(coords) ~= 'table' or type(coords.x) ~= 'number' or type(coords.y) ~= 'number' or type(coords.z) ~= 'number' then return end if type(heading) ~= 'number' or heading < 0 or heading > 360 then return end if coords.x < -4000 or coords.x > 4000 or coords.y < -4000 or coords.y > 8000 then return end @@ -156,22 +192,15 @@ RegisterNetEvent(resourceName .. ':server:cacheVehicle', function(plate, coords, } end) -AddEventHandler('playerDropped', function() - cacheVehicleCooldowns[source] = nil -end) - RegisterNetEvent('baseevents:leftVehicle', function(vehicle, seat, model, netId) local src = source if not CheckAuth(src) then return end - local veh = NetworkGetEntityFromNetworkId(netId) if not veh or veh == 0 then return end - local coords = GetEntityCoords(veh) local heading = GetEntityHeading(veh) local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '') if not plate or #plate == 0 then return end - TriggerClientEvent(resourceName .. ':client:checkVehicleClass', src, netId, plate, { x = coords.x, y = coords.y, z = coords.z }, heading) end) @@ -182,4 +211,161 @@ AddEventHandler('entityRemoved', function(entity) if not plate or plate == '' then return end plate = plate:gsub('%s+', '') vehicleCache[plate] = nil +end) + +-- ─── Patrols ────────────────────────────────────────────────────────────── + +ps.registerCallback(resourceName .. ":server:getPatrols", function(source) + if not CheckAuth(source) then return {} end + -- Return sorted array + local ordered = {} + for _, id in ipairs(patrolOrder) do + if patrols[id] then + ordered[#ordered + 1] = patrols[id] + end + end + return ordered +end) + +RegisterNetEvent(resourceName .. ":server:createPatrol", function(id, name, color) + local src = source + if not CheckAuth(src) then return end + if not isValidPatrolId(id) or not isValidName(name) or not isValidColor(color) then return end + if patrols[id] then return end + + local sortOrder = #patrolOrder + 1 + patrols[id] = { id = id, name = name, color = color, memberIds = {}, sortOrder = sortOrder } + patrolOrder[#patrolOrder + 1] = id + broadcastPatrols() + savePatrol(patrols[id]) +end) + +RegisterNetEvent(resourceName .. ":server:deletePatrol", function(id) + local src = source + if not CheckAuth(src) then return end + if not isValidPatrolId(id) then return end + + patrols[id] = nil + -- Remove from order list + for i = #patrolOrder, 1, -1 do + if patrolOrder[i] == id then + table.remove(patrolOrder, i) + break + end + end + deletePatrolFromDB(id) + saveOrder() + broadcastPatrols() +end) + +RegisterNetEvent(resourceName .. ":server:renamePatrol", function(id, newName) + local src = source + if not CheckAuth(src) then return end + if not isValidPatrolId(id) or not isValidName(newName) then return end + if not patrols[id] then return end + + patrols[id].name = newName + broadcastPatrols() + savePatrol(patrols[id]) +end) + +-- New order from client – ids is an array of patrol IDs in the desired order +RegisterNetEvent(resourceName .. ":server:reorderPatrols", function(ids) + local src = source + if not CheckAuth(src) then return end + if type(ids) ~= "table" then return end + + -- Validate: only known IDs, no duplicates + local seen = {} + local newOrder = {} + for _, id in ipairs(ids) do + if isValidPatrolId(id) and patrols[id] and not seen[id] then + seen[id] = true + newOrder[#newOrder + 1] = id + end + end + -- Append any missing IDs + for _, id in ipairs(patrolOrder) do + if not seen[id] then + newOrder[#newOrder + 1] = id + end + end + + patrolOrder = newOrder + saveOrder() + broadcastPatrols() +end) + +RegisterNetEvent(resourceName .. ":server:assignOfficer", function(patrolId, citizenId) + local src = source + if not CheckAuth(src) then return end + if not isValidPatrolId(patrolId) or not isValidCitizenId(citizenId) then return end + if not patrols[patrolId] then return end + + for _, patrol in pairs(patrols) do + for i = #patrol.memberIds, 1, -1 do + if patrol.memberIds[i] == citizenId then + table.remove(patrol.memberIds, i) + end + end + end + table.insert(patrols[patrolId].memberIds, citizenId) + broadcastPatrols("assigned", citizenId) + savePatrol(patrols[patrolId]) +end) + +RegisterNetEvent(resourceName .. ":server:removeFromPatrol", function(citizenId) + local src = source + if not CheckAuth(src) then return end + if not isValidCitizenId(citizenId) then return end + + for _, patrol in pairs(patrols) do + for i = #patrol.memberIds, 1, -1 do + if patrol.memberIds[i] == citizenId then + table.remove(patrol.memberIds, i) + savePatrol(patrol) + end + end + end + broadcastPatrols("removed", citizenId) +end) + +AddEventHandler("playerDropped", function() + cacheVehicleCooldowns[source] = nil + + if exports["qb-core"] then + local QBCore = exports["qb-core"]:GetCoreObject() + local player = QBCore.Functions.GetPlayer(source) + if player then + local citizenId = player.PlayerData.citizenid + for _, patrol in pairs(patrols) do + for i = #patrol.memberIds, 1, -1 do + if patrol.memberIds[i] == citizenId then + table.remove(patrol.memberIds, i) + savePatrol(patrol) + end + end + end + broadcastPatrols() + end + end +end) + +AddEventHandler("onResourceStart", function(res) + if res ~= resourceName then return end + local rows = MySQL.query.await("SELECT * FROM mdt_patrols ORDER BY sort_order ASC") + patrolOrder = {} + for _, row in ipairs(rows) do + patrols[row.id] = { + id = row.id, + name = row.name, + color = row.color, + memberIds = {}, + sortOrder = row.sort_order or 0, + } + patrolOrder[#patrolOrder + 1] = row.id + end + -- Clear members in DB as well + MySQL.execute("UPDATE mdt_patrols SET member_ids = '[]'", {}) + print("[MDT] " .. #rows .. " patrols loaded.") end) \ No newline at end of file From 70752b38c05a56676962b42939a7ce09b08b0727 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:31:38 +0200 Subject: [PATCH 098/144] Add files via upload --- client/keys.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/keys.lua b/client/keys.lua index 3daa614f..0e0023f8 100644 --- a/client/keys.lua +++ b/client/keys.lua @@ -147,7 +147,8 @@ function OpenMDT() MDTOpen = true SendNUI('setVisible', { visible = true, debugMode = Config.Debug }) - + SendMapUiState() + if isCivilian then -- Civilian mode: send auth with civilian flag local playerData = ps.getPlayerData() From 1dd17c64be8d1bdc3abcaf70f1a8f23e93d0b581 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:32:11 +0200 Subject: [PATCH 099/144] Add files via upload --- client/backend/tracking.lua | 103 ++++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 9 deletions(-) diff --git a/client/backend/tracking.lua b/client/backend/tracking.lua index 3895731c..fc9d2a90 100644 --- a/client/backend/tracking.lua +++ b/client/backend/tracking.lua @@ -1,24 +1,109 @@ +-- client_patrols.lua local resourceName = tostring(GetCurrentResourceName()) -RegisterNUICallback('getTracking', function(_, cb) +-- UI state held in Lua client (survives MDT open/close) +local mapUiState = { + sidebarOpen = true, + officersOpen = true, + patrolsOpen = true, +} + +-- ─── Server → NUI ───────────────────────────────────────────────────────── + +RegisterNetEvent(resourceName .. ":client:syncPatrols", function(patrols, action, citizenid) + SendNUIMessage({ type = "syncPatrols", data = patrols, action = action, citizenid = citizenid }) +end) + +RegisterNetEvent(resourceName .. ':client:checkVehicleClass', function(netId, plate, coords, heading) + local veh = NetworkGetEntityFromNetworkId(netId) + if not veh or veh == 0 then return end + if GetVehicleClass(veh) ~= 18 then return end + TriggerServerEvent(resourceName .. ':server:cacheVehicle', plate, coords, heading) +end) + +-- ─── UI State ───────────────────────────────────────────────────────────── + +-- Call this in client.lua after SendNUI('setVisible', { visible = true }) +function SendMapUiState() + SendNUIMessage({ type = "mapUiState", data = mapUiState }) +end + +RegisterNUICallback("saveMapUiState", function(data, cb) + if type(data.key) == "string" and type(data.value) == "boolean" then + mapUiState[data.key] = data.value + end + cb({}) +end) + +-- ─── Tracking ───────────────────────────────────────────────────────────── + +-- Register only once – always call cb() to prevent timeout +RegisterNUICallback("getTracking", function(_, cb) if not MDTOpen then - cb({ success = false, message = 'MDT is not open', data = {} }) + cb({ success = false, data = { vehicles = {}, bodycams = {} } }) return end - local tracking = ps.callback(resourceName .. ':server:getTracking') + local tracking = ps.callback(resourceName .. ":server:getTracking") if tracking then cb({ success = true, data = tracking }) else - cb({ success = false, message = 'Failed to fetch tracking data', data = {} }) + cb({ success = false, data = { vehicles = {}, bodycams = {} } }) end end) -RegisterNetEvent(resourceName .. ':client:checkVehicleClass', function(netId, plate, coords, heading) - local veh = NetworkGetEntityFromNetworkId(netId) - if not veh or veh == 0 then return end +-- ─── Patrols ────────────────────────────────────────────────────────────── - if GetVehicleClass(veh) ~= 18 then return end +RegisterNUICallback("getPatrols", function(_, cb) + local result = ps.callback(resourceName .. ":server:getPatrols") + cb({ success = true, data = result or {} }) +end) - TriggerServerEvent(resourceName .. ':server:cacheVehicle', plate, coords, heading) +RegisterNUICallback("createPatrol", function(data, cb) + if not MDTOpen then cb({ success = false }) return end + local id, name, color = data.id, data.name, data.color + if type(id) ~= "string" or type(name) ~= "string" or type(color) ~= "string" then + cb({ success = false }) return + end + TriggerServerEvent(resourceName .. ":server:createPatrol", id, name, color) + cb({ success = true }) +end) + +RegisterNUICallback("deletePatrol", function(data, cb) + if not MDTOpen then cb({ success = false }) return end + if type(data.id) ~= "string" then cb({ success = false }) return end + TriggerServerEvent(resourceName .. ":server:deletePatrol", data.id) + cb({ success = true }) +end) + +RegisterNUICallback("renamePatrol", function(data, cb) + if not MDTOpen then cb({ success = false }) return end + if type(data.id) ~= "string" or type(data.name) ~= "string" then + cb({ success = false }) return + end + TriggerServerEvent(resourceName .. ":server:renamePatrol", data.id, data.name) + cb({ success = true }) +end) + +RegisterNUICallback("assignOfficer", function(data, cb) + if not MDTOpen then cb({ success = false }) return end + if type(data.patrolId) ~= "string" or type(data.citizenId) ~= "string" then + cb({ success = false }) return + end + TriggerServerEvent(resourceName .. ":server:assignOfficer", data.patrolId, data.citizenId) + cb({ success = true }) +end) + +RegisterNUICallback("reorderPatrols", function(data, cb) + if not MDTOpen then cb({ success = false }) return end + if type(data.ids) ~= "table" then cb({ success = false }) return end + TriggerServerEvent(resourceName .. ":server:reorderPatrols", data.ids) + cb({ success = true }) +end) + +RegisterNUICallback("removeFromPatrol", function(data, cb) + if not MDTOpen then cb({ success = false }) return end + if type(data.citizenId) ~= "string" then cb({ success = false }) return end + TriggerServerEvent(resourceName .. ":server:removeFromPatrol", data.citizenId) + cb({ success = true }) end) \ No newline at end of file From 2d847bb030b74c8361d0b02892db3bf478f3ebaa Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:32:44 +0200 Subject: [PATCH 100/144] Add files via upload --- web/src/components/ContentArea.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/ContentArea.svelte b/web/src/components/ContentArea.svelte index cd5c3fbe..043e66f9 100644 --- a/web/src/components/ContentArea.svelte +++ b/web/src/components/ContentArea.svelte @@ -208,7 +208,7 @@ {:else if activeComponent === "roster"} {:else if activeComponent === "map"} - + {:else if activeComponent === "vehicles"} {:else if activeComponent === "weapons"} From 71aaa8216b6dbcee3aa79c3bd9dcb34bbf03424f Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:33:13 +0200 Subject: [PATCH 101/144] Add files via upload From d4c6a8f37ff640cacb8a7d9ca672d0611b825fb8 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:33:56 +0200 Subject: [PATCH 102/144] Add files via upload --- web/src/services/managementService.svelte.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/services/managementService.svelte.ts b/web/src/services/managementService.svelte.ts index c4290af7..682a57b0 100644 --- a/web/src/services/managementService.svelte.ts +++ b/web/src/services/managementService.svelte.ts @@ -36,9 +36,9 @@ export function createManagementService() { permissions = [...ALL_PERMISSION_KEYS]; // Dev mock: roles with escalating permissions (labels match whatever the job grades are) const basicPerms = ["citizens_search", "bolos_view", "vehicles_search", "weapons_search", "reports_view", "warrants_view", "charges_view"]; - const midPerms = [...basicPerms, "reports_create", "cases_view", "evidence_view", "bolos_create", "dispatch_attach", "dispatch_route"]; - const seniorPerms = [...midPerms, "cases_create", "cases_edit", "evidence_create", "evidence_transfer", "reports_delete", "warrants_issue", "cameras_view", "bodycams_view", "citizens_edit_licenses", "vehicles_edit_dmv", "weapons_add"]; - const commandPerms = [...seniorPerms, "cases_delete", "evidence_upload", "warrants_close", "charges_edit", "management_activity", "management_bulletins", "roster_manage_officers", "roster_manage_certifications"]; + const midPerms = [...basicPerms, "reports_create", "cases_view", "evidence_view", "bolos_create", "dispatch_attach", "dispatch_route", "map_patrols_view"]; + const seniorPerms = [...midPerms, "cases_create", "cases_edit", "evidence_create", "evidence_transfer", "reports_delete", "warrants_issue", "cameras_view", "bodycams_view", "citizens_edit_licenses", "vehicles_edit_dmv", "weapons_add", "map_patrols_manage",]; + const commandPerms = [...seniorPerms, "cases_delete", "evidence_upload", "warrants_close", "charges_edit", "management_activity", "management_bulletins", "roster_manage_officers", "roster_manage_certifications", "map_patrols_edit"]; roles = [ { key: "0", label: "Grade 0", permissions: basicPerms, isBoss: false }, { key: "1", label: "Grade 1", permissions: midPerms, isBoss: false }, From c7bf4f4d01407319cf25464cf4a62cbefcb72ebb Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:34:20 +0200 Subject: [PATCH 103/144] Add files via upload --- web/src/pages/Map.svelte | 1284 +++++++++++++++++++++++++++++++++----- 1 file changed, 1121 insertions(+), 163 deletions(-) diff --git a/web/src/pages/Map.svelte b/web/src/pages/Map.svelte index 5d012722..6da8c9fb 100644 --- a/web/src/pages/Map.svelte +++ b/web/src/pages/Map.svelte @@ -6,21 +6,100 @@ import { isEnvBrowser } from "../utils/misc"; import { NUI_EVENTS } from "../constants/nuiEvents"; import { globalNotifications } from "../services/notificationService.svelte"; + import type { AuthService } from "../services/authService.svelte"; + interface Props { + authService?: AuthService; + } + let { authService }: Props = $props(); + + // Default true = everyone can use it unless authService explicitly denies + let canViewPatrols = $derived(authService ? (authService.hasPermission("map_patrols_view") ?? true) : true); + let canManagePatrols = $derived(authService ? (authService.hasPermission("map_patrols_manage") ?? true) : true); + let canEditPatrols = $derived(authService ? (authService.hasPermission("map_patrols_edit") ?? true) : true); + + // ─── Map state ─────────────────────────────────────────────────────────── let mapContainer: HTMLDivElement | null = null; let map: L.Map | null = null; let mapInitialized = false; let refreshTimer: ReturnType | null = null; let tabVisible = $state(true); - - let showVehicles = $state(true); - let showBodycams = $state(true); - let iconStyle = $state<"dot" | "badge">("dot"); + let showVehicles = $state(localStorage.getItem("mdt_map_vehicles") !== "false"); + let showBodycams = $state(localStorage.getItem("mdt_map_bodycams") !== "false"); + let showPatrols = $state(localStorage.getItem("mdt_map_patrols_layer") !== "false"); + let iconStyle = $state<"dot" | "badge">( + (localStorage.getItem("mdt_map_icon_style") as "dot" | "badge") ?? "dot" + ); let vehicleLayer = L.layerGroup(); let bodycamLayer = L.layerGroup(); + let patrolLayer = L.layerGroup(); + + // ─── Sidebar state ──────────────────────────────────────────────────────── + // localStorage as default – overridden by Lua client on open + let sidebarOpen = $state(localStorage.getItem("mdt_map_sidebar") !== "false"); + let officersOpen = $state(localStorage.getItem("mdt_map_officers") !== "false"); + let patrolsOpen = $state(localStorage.getItem("mdt_map_patrols") !== "false"); + + function toggleSidebar() { + sidebarOpen = !sidebarOpen; + localStorage.setItem("mdt_map_sidebar", String(sidebarOpen)); + // Also save in Lua client (survives resource restart) + fetchNui(NUI_EVENTS.MAP.SAVE_UI_STATE, { key: "sidebarOpen", value: sidebarOpen }, {}).catch(() => {}); + } + function toggleOfficers() { + officersOpen = !officersOpen; + localStorage.setItem("mdt_map_officers", String(officersOpen)); + fetchNui(NUI_EVENTS.MAP.SAVE_UI_STATE, { key: "officersOpen", value: officersOpen }, {}).catch(() => {}); + } + function togglePatrols() { + patrolsOpen = !patrolsOpen; + localStorage.setItem("mdt_map_patrols", String(patrolsOpen)); + fetchNui(NUI_EVENTS.MAP.SAVE_UI_STATE, { key: "patrolsOpen", value: patrolsOpen }, {}).catch(() => {}); + } + + // Sidebar-Breite: 260px pro offenem Panel + 36px pro zugeklapptem + 1px Divider + let sidebarWidth = $derived( + (officersOpen ? 260 : 36) + 1 + (patrolsOpen ? 260 : 36) + ); + // ─── Patrol types ───────────────────────────────────────────────────────── + type Bodycam = { + citizenid: string; + name: string; + callsign?: string; + rank?: string; + coords: { x: number; y: number; z: number }; + heading?: number; + }; + + type Patrol = { + id: string; + name: string; + color: string; + memberIds: string[]; + }; + + // ─── Officers & Patrols ─────────────────────────────────────────────────── + let officers = $state([]); + let patrols = $state([]); + + // New patrol form + let newPatrolName = $state(""); + let newPatrolColor = $state("#38bdf8"); + let showCreateForm = $state(false); + + // Edit patrol name + let editingPatrolId = $state(null); + let editingPatrolName = $state(""); + + const PATROL_COLORS = [ + "#38bdf8", "#f97316", "#a855f7", "#22c55e", + "#ef4444", "#eab308", "#ec4899", "#14b8a6" + ]; + + // ─── Helpers ────────────────────────────────────────────────────────────── const offsetX = 13; const offsetY = 5; @@ -30,17 +109,19 @@ function getTrackConfig(kind: "vehicle" | "bodycam") { if (kind === "vehicle") return { color: "#f97316", fill: "#fb923c", label: "V" }; - if (kind === "bodycam") return { color: "#a855f7", fill: "#c084fc", label: "B" }; - return { color: "#38bdf8", fill: "#0ea5e9", label: "O" }; + return { color: "#a855f7", fill: "#c084fc", label: "B" }; } function createMarker( kind: "vehicle" | "bodycam", coords: { x: number; y: number }, label: string, - heading?: number + heading?: number, + patrolColor?: string ) { const config = getTrackConfig(kind); + const dotColor = patrolColor ?? config.fill; + const borderColor = patrolColor ? patrolColor : config.color; const latLng = toMapLatLng(coords); const rotation = heading != null ? 360 - heading : 0; const hasHeading = heading != null; @@ -51,10 +132,10 @@ className: "", html: `
-
+
${config.label}
- ${hasHeading ? `
` : ""} + ${hasHeading ? `
` : ""}
`, iconSize: [28, 28], @@ -68,8 +149,8 @@ className: "", html: `
-
- ${hasHeading ? `
` : ""} +
+ ${hasHeading ? `
` : ""}
`, iconSize: [20, 20], @@ -85,6 +166,57 @@ return null; } + function getOfficerPatrol(citizenid: string): Patrol | undefined { + return patrols.find(p => p.memberIds.includes(citizenid)); + } + + function unassignedOfficers() { + return officers.filter(o => !patrols.some(p => p.memberIds.includes(o.citizenid))); + } + + // ─── Patrol map labels ──────────────────────────────────────────────────── + function refreshPatrolLabels() { + patrolLayer.clearLayers(); + if (!showPatrols) return; + + for (const patrol of patrols) { + const members = officers.filter(o => patrol.memberIds.includes(o.citizenid)); + if (members.length === 0) continue; + + // Centroid berechnen + const centroid = members.reduce( + (acc, o) => ({ x: acc.x + o.coords.x, y: acc.y + o.coords.y }), + { x: 0, y: 0 } + ); + centroid.x /= members.length; + centroid.y /= members.length; + + // Place label at the member closest to the centroid + + const anchor = members.reduce((closest, o) => { + const dx = o.coords.x - centroid.x; + const dy = o.coords.y - centroid.y; + const cdx = closest.coords.x - centroid.x; + const cdy = closest.coords.y - centroid.y; + return (dx*dx + dy*dy) < (cdx*cdx + cdy*cdy) ? o : closest; + }); + + const latLng = toMapLatLng(anchor.coords); + + L.marker(latLng as any, { + icon: L.divIcon({ + className: "", + html: `
${patrol.name}
`, + iconSize: [null as any, null as any], + iconAnchor: [0, 24], // offset upward above the marker + }), + interactive: false, + zIndexOffset: -100, + }).addTo(patrolLayer); + } + } + + // ─── Refresh tracking ───────────────────────────────────────────────────── async function refreshTracking() { if (!map || !tabVisible) return; if (isEnvBrowser()) return; @@ -93,15 +225,52 @@ const response = await fetchNui( NUI_EVENTS.MAP.GET_TRACKING, {}, - { data: { vehicles: [], bodycams: [] } } + { data: { vehicles: [], bodycams: [] } }, + 3000, ); + + // Bei Fehler oder leerem Response bestehende Daten behalten + const success = (response as any).success; + if (success === false) return; + const data = (response as any).data ?? response; + const bodycams = (data as any).bodycams; + const vehicles = (data as any).vehicles; + + // Nur updaten wenn der Server wirklich Daten geliefert hat + if (!Array.isArray(bodycams) && !Array.isArray(vehicles)) return; vehicleLayer.clearLayers(); bodycamLayer.clearLayers(); + const freshOfficers: Bodycam[] = []; + + for (const bodycam of bodycams || []) { + const coords = normalizeCoords((bodycam as any).coords); + if (!coords) continue; + + const bc: Bodycam = { + citizenid: bodycam.citizenid ?? bodycam.name ?? String(Math.random()), + name: bodycam.name ?? "", + callsign: bodycam.callsign, + rank: bodycam.rank, + coords: { x: coords.x, y: coords.y, z: bodycam.coords?.z ?? 0 }, + heading: bodycam.heading, + }; + freshOfficers.push(bc); + + if (showBodycams) { + const patrol = getOfficerPatrol(bc.citizenid); + const label = `${[bc.rank, bc.callsign].filter(Boolean).join(" | ")}${bc.name ? " | " + bc.name : ""}`; + const color = patrol?.color ?? "#6b7280"; + createMarker("bodycam", coords, label, bodycam.heading, color).addTo(bodycamLayer); + } + } + + officers = freshOfficers; + if (showVehicles) { - for (const vehicle of (data as any).vehicles || []) { + for (const vehicle of vehicles || []) { const coords = normalizeCoords((vehicle as any).coords); if (!coords) continue; const label = `${(vehicle as any).plate || ""}`.trim(); @@ -109,37 +278,269 @@ } } - if (showBodycams) { - for (const bodycam of (data as any).bodycams || []) { - const coords = normalizeCoords((bodycam as any).coords); - if (!coords) continue; - const label = `${[(bodycam as any).rank, (bodycam as any).callsign].filter(Boolean).join(" | ")}${(bodycam as any).name ? " | " + (bodycam as any).name : ""}`; - createMarker("bodycam", coords, label, (bodycam as any).heading).addTo(bodycamLayer); + refreshPatrolLabels(); + } catch { + // Timeout oder Netzwerkfehler – bestehende Officer/Marker behalten + } + } + + // ─── Mouse-based Drag System (kein HTML5 draggable – CEF-kompatibel) ────── + type DragKind = "officer" | "patrol"; + type DragState = { + kind: DragKind; + id: string; + label: string; + x: number; + y: number; + active: boolean; + }; + + let drag = $state(null); + let dragOverPatrolId = $state(null); + let dragOverPatrolSortId = $state(null); + let isDragging = $state(false); + + // Ghost element for visual drag feedback + let ghostEl: HTMLDivElement | null = null; + + function createGhost(label: string, kind: DragKind, x: number, y: number) { + removeGhost(); + ghostEl = document.createElement("div"); + ghostEl.className = `drag-ghost drag-ghost--${kind}`; + ghostEl.textContent = label; + ghostEl.style.left = `${x + 12}px`; + ghostEl.style.top = `${y - 16}px`; + document.body.appendChild(ghostEl); + } + + function moveGhost(x: number, y: number) { + if (!ghostEl) return; + ghostEl.style.left = `${x + 12}px`; + ghostEl.style.top = `${y - 16}px`; + } + + function removeGhost() { + ghostEl?.remove(); + ghostEl = null; + } + + // Patrol-Card-Elemente per data-patrol-id finden + function getPatrolIdFromPoint(x: number, y: number): string | null { + const els = document.elementsFromPoint(x, y); + for (const el of els) { + const card = (el as HTMLElement).closest("[data-patrol-id]") as HTMLElement | null; + if (card) return card.dataset.patrolId ?? null; + } + return null; + } + + function onMouseDown(e: MouseEvent, kind: DragKind, id: string, label: string) { + if (e.button !== 0) return; + e.preventDefault(); + drag = { kind, id, label, x: e.clientX, y: e.clientY, active: false }; + } + + function onGlobalMouseMove(e: MouseEvent) { + if (!drag) return; + + if (!drag.active) { + // Only count as drag after 5px movement + const dx = e.clientX - drag.x; + const dy = e.clientY - drag.y; + if (Math.sqrt(dx*dx + dy*dy) < 5) return; + drag.active = true; + isDragging = true; + createGhost(drag.label, drag.kind, e.clientX, e.clientY); + } + + moveGhost(e.clientX, e.clientY); + + const pid = getPatrolIdFromPoint(e.clientX, e.clientY); + if (drag.kind === "officer") { + dragOverPatrolId = pid; + dragOverPatrolSortId = null; + } else { + dragOverPatrolSortId = pid !== drag.id ? pid : null; + dragOverPatrolId = null; + } + } + + function onGlobalMouseUp(e: MouseEvent) { + if (!drag) return; + + if (drag.active) { + const pid = getPatrolIdFromPoint(e.clientX, e.clientY); + + if (drag.kind === "officer") { + if (pid) { + assignOfficer(drag.id, pid); + } else { + // Auf Officers-Panel losgelassen → aus Streife entfernen + const el = document.elementFromPoint(e.clientX, e.clientY); + if (el?.closest(".panel-officers")) { + removeFromPatrol(drag.id); + } + } + } else if (drag.kind === "patrol" && pid && pid !== drag.id) { + // Streifen sortieren + const arr = [...patrols]; + const fromIdx = arr.findIndex(p => p.id === drag!.id); + const toIdx = arr.findIndex(p => p.id === pid); + if (fromIdx >= 0 && toIdx >= 0) { + const [moved] = arr.splice(fromIdx, 1); + arr.splice(toIdx, 0, moved); + patrols = arr; + syncPatrolOrder(arr); } } + } + + removeGhost(); + drag = null; + isDragging = false; + dragOverPatrolId = null; + dragOverPatrolSortId = null; + } + function handleNuiMessage(event: MessageEvent) { + const { type, data } = event.data ?? {}; + + if (type === "setVisible") { + if (data?.visible === true) { + // Short delay to ensure MDTOpen is set in Lua client + setTimeout(() => { + refreshTracking(); + loadPatrols(); + }, 300); + } + return; + } + + if (type === "mapUiState") { + // Lua client state overrides localStorage (authoritative after resource restart) + if (typeof data?.sidebarOpen === "boolean") { sidebarOpen = data.sidebarOpen; localStorage.setItem("mdt_map_sidebar", String(sidebarOpen)); } + if (typeof data?.officersOpen === "boolean") { officersOpen = data.officersOpen; localStorage.setItem("mdt_map_officers", String(officersOpen)); } + if (typeof data?.patrolsOpen === "boolean") { patrolsOpen = data.patrolsOpen; localStorage.setItem("mdt_map_patrols", String(patrolsOpen)); } + return; + } + + if (type === "syncPatrols") { + // Server sends sorted array + patrols = Array.isArray(data) ? data as Patrol[] : Object.values(data as Record); + refreshPatrolLabels(); + // Trigger animation for all clients based on server hint + const msg = event.data as any; + if (msg.action === "assigned" && msg.citizenid) flashAssigned(msg.citizenid); + if (msg.action === "removed" && msg.citizenid) flashRemoved(msg.citizenid); + } + } + + // ─── Assignment animation state ─────────────────────────────────────────── + let recentlyAssigned = $state>(new Set()); + let recentlyRemoved = $state>(new Set()); + + function flashAssigned(citizenid: string) { + recentlyAssigned = new Set([...recentlyAssigned, citizenid]); + setTimeout(() => { + recentlyAssigned = new Set([...recentlyAssigned].filter(id => id !== citizenid)); + }, 700); + } + + function flashRemoved(citizenid: string) { + recentlyRemoved = new Set([...recentlyRemoved, citizenid]); + setTimeout(() => { + recentlyRemoved = new Set([...recentlyRemoved].filter(id => id !== citizenid)); + }, 700); + } + async function loadPatrols() { + if (isEnvBrowser()) return; + try { + const res = await fetchNui(NUI_EVENTS.MAP.GET_PATROLS, {}, { success: true, data: [] }); + const data = (res as any).data ?? res; + patrols = Array.isArray(data) ? data as Patrol[] : Object.values(data as Record); + refreshPatrolLabels(); } catch { - globalNotifications.error("Failed to refresh tracking"); + globalNotifications.error("Failed to load patrols"); + } + } + + // Doppelter Name? + function patrolNameExists(name: string, excludeId?: string) { + return patrols.some(p => p.name.toLowerCase() === name.toLowerCase() && p.id !== excludeId); + } + + async function createPatrol() { + const name = newPatrolName.trim(); + if (!name) return; + if (patrolNameExists(name)) { + globalNotifications.error(`Patrol "${name}" already exists`); + return; } + const id = crypto.randomUUID(); + try { + await fetchNui(NUI_EVENTS.MAP.CREATE_PATROL, { id, name, color: newPatrolColor }, { success: true }); + } catch { /* Server broadcasts syncPatrols */ } + newPatrolName = ""; + showCreateForm = false; } + async function deletePatrol(id: string) { + try { + await fetchNui(NUI_EVENTS.MAP.DELETE_PATROL, { id }, { success: true }); + } catch { /* Server broadcastet syncPatrols */ } + } + + async function renamePatrolOnServer(id: string, name: string) { + if (patrolNameExists(name, id)) { + globalNotifications.error(`Patrol "${name}" already exists`); + return; + } + try { + await fetchNui(NUI_EVENTS.MAP.RENAME_PATROL, { id, name }, { success: true }); + } catch { /* Server broadcastet syncPatrols */ } + } + + async function assignOfficer(officerId: string, patrolId: string) { + try { + await fetchNui(NUI_EVENTS.MAP.ASSIGN_OFFICER, { patrolId, citizenId: officerId }, { success: true }); + } catch { } + } + + async function removeFromPatrol(officerId: string) { + try { + await fetchNui(NUI_EVENTS.MAP.REMOVE_FROM_PATROL, { citizenId: officerId }, { success: true }); + } catch { } + } + + // Move patrol up/down in the list + function movePatrol(id: string, dir: -1 | 1) { + const idx = patrols.findIndex(p => p.id === id); + if (idx < 0) return; + const newIdx = idx + dir; + if (newIdx < 0 || newIdx >= patrols.length) return; + const arr = [...patrols]; + [arr[idx], arr[newIdx]] = [arr[newIdx], arr[idx]]; + patrols = arr; + syncPatrolOrder(arr); + } + + function syncPatrolOrder(arr: Patrol[]) { + fetchNui(NUI_EVENTS.MAP.REORDER_PATROLS, { ids: arr.map(p => p.id) }, { success: true }).catch(() => {}); + } + + // ─── NUI Message Listener (Server → Client → NUI sync) ─────────────────── function handleVisibilityChange() { tabVisible = !document.hidden; } function syncLayerVisibility() { if (!map) return; - - if (showVehicles) { - if (!map.hasLayer(vehicleLayer)) vehicleLayer.addTo(map); - } else if (map.hasLayer(vehicleLayer)) { - map.removeLayer(vehicleLayer); - } - - if (showBodycams) { - if (!map.hasLayer(bodycamLayer)) bodycamLayer.addTo(map); - } else if (map.hasLayer(bodycamLayer)) { - map.removeLayer(bodycamLayer); - } + const toggle = (layer: L.LayerGroup, show: boolean) => { + if (show && !map!.hasLayer(layer)) layer.addTo(map!); + else if (!show && map!.hasLayer(layer)) map!.removeLayer(layer); + }; + toggle(vehicleLayer, showVehicles); + toggle(bodycamLayer, showBodycams); + toggle(patrolLayer, showPatrols); } function getCustomCRS() { @@ -191,6 +592,7 @@ vehicleLayer = L.layerGroup().addTo(map); bodycamLayer = L.layerGroup().addTo(map); + patrolLayer = L.layerGroup().addTo(map); syncLayerVisibility(); refreshTracking(); @@ -205,41 +607,48 @@ onMount(() => { document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("message", handleNuiMessage); + window.addEventListener("mousemove", onGlobalMouseMove); + window.addEventListener("mouseup", onGlobalMouseUp); initializeMap(); + loadPatrols(); }); onDestroy(() => { document.removeEventListener("visibilitychange", handleVisibilityChange); - - if (map) { - map.remove(); - map = null; - mapInitialized = false; - } - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; - } + window.removeEventListener("message", handleNuiMessage); + window.removeEventListener("mousemove", onGlobalMouseMove); + window.removeEventListener("mouseup", onGlobalMouseUp); + removeGhost(); + if (map) { map.remove(); map = null; mapInitialized = false; } + if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }); $effect(() => { syncLayerVisibility(); }); $effect(() => { iconStyle; refreshTracking(); }); + $effect(() => { showPatrols; refreshPatrolLabels(); });
-
+
+ +
Tracking
+
@@ -247,20 +656,8 @@
Style
- - + +
@@ -268,14 +665,243 @@
Vehicle - Bodycam + Unassigned + {#each patrols.filter(p => p.memberIds.length > 0) as patrol} + {patrol.name} + {/each} +
+
+ + +
+ + + {#if canViewPatrols} + + + + -
+ {/if} +
\ No newline at end of file From 3c0840c8dacd1d6ca9b59cadd886f8e93e8ae76e Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Tue, 26 May 2026 09:34:38 +0200 Subject: [PATCH 104/144] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 89e39b04..a3f6b32d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +DONT FORGET TO BUILT IT THIS CHANGES NEEDS TO BE DONE: ```lua CREATE TABLE IF NOT EXISTS mdt_patrols ( From d870d6041c8223f29d2294a5857da033b028cb42 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Thu, 28 May 2026 20:34:45 +0200 Subject: [PATCH 105/144] Update qbcore.sql --- sql/qbcore.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/qbcore.sql b/sql/qbcore.sql index b0d6fc96..5d85f1cb 100644 --- a/sql/qbcore.sql +++ b/sql/qbcore.sql @@ -355,7 +355,7 @@ CREATE TABLE IF NOT EXISTS `mdt_reports_involved` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `reportid` int(10) unsigned NOT NULL, `citizenid` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, - `type` varchar(7) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, + `type` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, `notes` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, PRIMARY KEY (`id`), KEY `FK_mdt_reports_involved_mdt_reports` (`reportid`), From a44d06f0e2f0d9225e1782a3ae98fac074b7169c Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Thu, 28 May 2026 20:35:09 +0200 Subject: [PATCH 106/144] Update qbx.sql --- sql/qbx.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/qbx.sql b/sql/qbx.sql index fb8dff33..445e6c8b 100644 --- a/sql/qbx.sql +++ b/sql/qbx.sql @@ -355,7 +355,7 @@ CREATE TABLE IF NOT EXISTS `mdt_reports_involved` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `reportid` int(10) unsigned NOT NULL, `citizenid` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `type` varchar(7) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `type` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `notes` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, PRIMARY KEY (`id`), KEY `FK_mdt_reports_involved_mdt_reports` (`reportid`), From 100e159d7a280b744724578c35e6a814587fb9f4 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Thu, 28 May 2026 20:36:04 +0200 Subject: [PATCH 107/144] fixes no notes/types for vitims - client fixes no notes/types for vitims --- client/backend/reports.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/backend/reports.lua b/client/backend/reports.lua index b7922576..0918e2d5 100644 --- a/client/backend/reports.lua +++ b/client/backend/reports.lua @@ -83,8 +83,8 @@ RegisterNUICallback('saveReport', function(data, cb) if victim.citizenid then table.insert(involved, { citizenid = victim.citizenid, - type = 'victim', - notes = '' + type = victim.type or 'victim', + notes = victim.notes or '' }) end end From f269d75526c40b55e74d696d50299fc2a9ccf03d Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Thu, 28 May 2026 20:36:20 +0200 Subject: [PATCH 108/144] fixes no notes/types for vitims fixes no notes/types for vitims --- server/backend/reports.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/backend/reports.lua b/server/backend/reports.lua index b29b99cc..c44a0e7b 100644 --- a/server/backend/reports.lua +++ b/server/backend/reports.lua @@ -1135,4 +1135,4 @@ ps.registerCallback(resourceName .. ':server:getReportsByPlate', function(source ]], { plate }) return rows or {} -end) +end) \ No newline at end of file From fae3c05c5fac4221bed1b5a45cfb0fe089581268 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Thu, 28 May 2026 20:36:49 +0200 Subject: [PATCH 109/144] fixes no notes/types for vitims fixes no notes/types for vitims --- web/src/components/InvolvedPersons.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/InvolvedPersons.svelte b/web/src/components/InvolvedPersons.svelte index 61d695d4..092fd94a 100644 --- a/web/src/components/InvolvedPersons.svelte +++ b/web/src/components/InvolvedPersons.svelte @@ -336,4 +336,4 @@ border: 1px dashed var(--border-color); border-radius: 0.375rem; } - + \ No newline at end of file From d74a4dfcdb90bce024b378543971a61b21a3c701 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Thu, 28 May 2026 20:37:08 +0200 Subject: [PATCH 110/144] fixes no notes/types for vitims fixes no notes/types for vitims --- web/src/components/report-editor/VictimsManager.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/report-editor/VictimsManager.svelte b/web/src/components/report-editor/VictimsManager.svelte index 3acca7e0..e4e77524 100644 --- a/web/src/components/report-editor/VictimsManager.svelte +++ b/web/src/components/report-editor/VictimsManager.svelte @@ -31,6 +31,7 @@ secondaryInfo={`ID: ${victim.citizenid}`} type={victim.type} typeOptions={VICTIM_TYPES} + notes={victim.notes} {onRemove} onUpdate={updateVictim} /> From 876eb8193c05d6adb823aa344c02d21de9cab3bc Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Thu, 28 May 2026 20:37:50 +0200 Subject: [PATCH 111/144] fixes no notes/types for vitims fixes no notes/types for vitims --- web/src/services/reportService.svelte.ts | 25 +++++++++++------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/web/src/services/reportService.svelte.ts b/web/src/services/reportService.svelte.ts index 87cb6dec..fe5b9421 100644 --- a/web/src/services/reportService.svelte.ts +++ b/web/src/services/reportService.svelte.ts @@ -262,19 +262,15 @@ export function createReportService() { function normalizeInvolved(involved: any): Report["involved"] { const parsed = parseJsonString(involved); - const list = Array.isArray(parsed) - ? parsed - : Array.isArray(involved) - ? involved - : []; - const normalized = { - officers: [], - suspects: [], - victims: [], - } as Report["involved"]; + const list = Array.isArray(parsed) ? parsed : Array.isArray(involved) ? involved : []; + + const normalized = { officers: [], suspects: [], victims: [] } as Report["involved"]; + + const VICTIM_TYPES = new Set(["victim", "primary", "secondary", "witness", "complainant"]); for (const entry of list) { const type = (entry?.type || "").toLowerCase(); + if (type === "officer") { normalized.officers.push({ id: crypto.randomUUID(), @@ -284,12 +280,13 @@ export function createReportService() { type: entry?.type || "Officer", notes: entry?.notes || "", }); - } else if (type === "victim") { + } else if (VICTIM_TYPES.has(type)) { normalized.victims.push({ id: entry?.citizenid || crypto.randomUUID(), citizenid: entry?.citizenid || "", fullName: entry?.name || entry?.fullname || "Unknown", - type: entry?.type || "Victim", + type: entry?.type || "victim", + notes: entry?.notes || "", }); } else { normalized.suspects.push({ @@ -521,8 +518,8 @@ export function createReportService() { citizenid: victim.citizenid || "", fullName: victim.fullName, type: "Primary", + notes: "", // ← fehlte }; - return { ...report, involved: { @@ -678,4 +675,4 @@ export function createReportService() { }; } -export type ReportService = ReturnType; +export type ReportService = ReturnType; \ No newline at end of file From 6def7378b007ddf4df91babe46bad2eaeb8c1cd2 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Thu, 28 May 2026 20:38:21 +0200 Subject: [PATCH 112/144] fixes no notes/types for vitims fixes no notes/types for vitims --- web/src/interfaces/IReportEditor.ts | 123 ++++++++++++++-------------- 1 file changed, 62 insertions(+), 61 deletions(-) diff --git a/web/src/interfaces/IReportEditor.ts b/web/src/interfaces/IReportEditor.ts index 931ab363..804b1e3c 100644 --- a/web/src/interfaces/IReportEditor.ts +++ b/web/src/interfaces/IReportEditor.ts @@ -1,83 +1,84 @@ export interface Report { - id?: string; - title: string; - reportId: string; - officer: string; - type: string; - created: number; - lastUpdated: number; - content: string; - tags: string[]; - involved: { - officers: Officer[]; - suspects: Suspect[]; - victims: Victim[]; - }; - evidence: Evidence[]; - charges: ReportCharge[]; - restrictions: string[]; - vehicles: ReportVehicle[]; + id?: string; + title: string; + reportId: string; + officer: string; + type: string; + created: number; + lastUpdated: number; + content: string; + tags: string[]; + involved: { + officers: Officer[]; + suspects: Suspect[]; + victims: Victim[]; + }; + evidence: Evidence[]; + charges: ReportCharge[]; + restrictions: string[]; + vehicles: ReportVehicle[]; } export interface Officer { - id: string; - citizenid: string; - fullName: string; - badgeId: string; - type: string; - notes: string; + id: string; + citizenid: string; + fullName: string; + badgeId: string; + type: string; + notes: string; } export interface Suspect { - id: string; - citizenid: string; - fullName: string; - notes: string; - warrantActive?: boolean; - profileImage?: string; - fingerprint?: string; + id: string; + citizenid: string; + fullName: string; + notes: string; + warrantActive?: boolean; + profileImage?: string; + fingerprint?: string; } export interface Victim { - id: string; - citizenid: string; - fullName: string; - type: string; + id: string; + citizenid: string; + fullName: string; + type: string; + notes: string; } export interface Evidence { - id: string; - title: string; - type: string; - serial: string; - notes: string; - images: string[]; - caseId?: string; + id: string; + title: string; + type: string; + serial: string; + notes: string; + images: string[]; + caseId?: string; } export interface ReportVehicle { - plate: string; - vehicle_label: string; - owner_name: string; - owner_citizenid?: string; + plate: string; + vehicle_label: string; + owner_name: string; + owner_citizenid?: string; } export interface ReportCharge { - id: string; - citizenid: string; - suspectName: string; - charge: string; - count: number; - time: number; - fine: number; + id: string; + citizenid: string; + suspectName: string; + charge: string; + count: number; + time: number; + fine: number; } export interface SearchResult { - id: string; - fullName: string; - badgeId?: string; - citizenid?: string; - rank?: string; - image?: string; - fingerprint?: string; -} + id: string; + fullName: string; + badgeId?: string; + citizenid?: string; + rank?: string; + image?: string; + fingerprint?: string; +} \ No newline at end of file From 53258251c9182ad5a08ea719f42e8aa67a98c6f8 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Fri, 29 May 2026 01:36:00 +0200 Subject: [PATCH 113/144] Update README.md --- README.md | 52 ++++++++++++++++++---------------------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index a3f6b32d..223d0cf4 100644 --- a/README.md +++ b/README.md @@ -9,28 +9,13 @@ CREATE TABLE IF NOT EXISTS mdt_patrols ( member_ids TEXT NOT NULL DEFAULT '[]' ); -ALTER TABLE mdt_reports_evidence - ADD COLUMN title VARCHAR(255) NOT NULL DEFAULT '' AFTER reportid, - ADD COLUMN images LONGTEXT NULL DEFAULT NULL AFTER stored; - -ALTER TABLE `mdt_weapons` ADD COLUMN `flags` JSON DEFAULT NULL; - -ALTER TABLE player_vehicles -MODIFY COLUMN mdt_vehicle_status VARCHAR(500) DEFAULT 'valid'; - CREATE TABLE IF NOT EXISTS `mdt_bulletin_posts` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(255) NOT NULL, `content` LONGTEXT NOT NULL, `author` VARCHAR(100) NOT NULL, `author_rank` VARCHAR(100) NOT NULL DEFAULT '', - `category` ENUM( - 'announcement', - 'operations', - 'training', - 'general', - 'warrants' - ) NOT NULL DEFAULT 'general', + `category` VARCHAR(48) NOT NULL DEFAULT 'general', `priority` ENUM( 'low', 'normal', @@ -42,31 +27,30 @@ CREATE TABLE IF NOT EXISTS `mdt_bulletin_posts` ( `created_by` VARCHAR(60) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_job` (`job`), KEY `idx_category` (`category`), KEY `idx_pinned` (`pinned`), + KEY `idx_deleted_at` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - CREATE TABLE IF NOT EXISTS `mdt_bulletin_settings` ( - `key` VARCHAR(64) NOT NULL COMMENT 'Setting key, e.g., bulletin_categories', - `value` LONGTEXT NOT NULL COMMENT 'JSON-encoded value', - `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last Change', - PRIMARY KEY (`key`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - COMMENT='MDT – Key-Value-Store for Bulletin-Einstellungen'; +ALTER TABLE `mdt_bulletin_posts` + MODIFY COLUMN `category` VARCHAR(48) NOT NULL DEFAULT 'general'; -INSERT IGNORE INTO `mdt_bulletin_settings` (`key`, `value`) -VALUES ( - 'bulletin_categories', - '[ - {"value":"announcement","label":"Announcements","icon":"campaign"}, - {"value":"operations", "label":"Operations", "icon":"local_police"}, - {"value":"training", "label":"Training", "icon":"school"}, - {"value":"general", "label":"General", "icon":"forum"} - ]' -); +CREATE TABLE IF NOT EXISTS `mdt_bulletin_categories` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `job` VARCHAR(50) NOT NULL, + `value` VARCHAR(48) NOT NULL, + `label` VARCHAR(48) NOT NULL, + `icon` VARCHAR(48) NOT NULL DEFAULT 'label', + `color` VARCHAR(7) NOT NULL DEFAULT '#6B7280', + `sort_order` SMALLINT NOT NULL DEFAULT 0, + `is_default` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_job_value` (`job`, `value`), + INDEX `idx_job_order` (`job`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` # ps-mdt v3 From e89e7aefdb7e0eda42ff084fa328e0a6777ea28e Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 00:30:01 +0200 Subject: [PATCH 114/144] Add files via upload --- server/backend/bulletinboard.lua | 362 +++++++++++++++++++++++++++---- server/backend/dashboard.lua | 267 +++++------------------ 2 files changed, 376 insertions(+), 253 deletions(-) diff --git a/server/backend/bulletinboard.lua b/server/backend/bulletinboard.lua index 13f26998..2d7817ac 100644 --- a/server/backend/bulletinboard.lua +++ b/server/backend/bulletinboard.lua @@ -1,5 +1,9 @@ local resourceName = tostring(GetCurrentResourceName()) +-- ════════════════════════════════════════════════════════════ +-- Bulletin Posts +-- ════════════════════════════════════════════════════════════ + -- ── Get all bulletin posts for the officer's department ────── ps.registerCallback(resourceName .. ':server:getBulletinPosts', function(source) @@ -11,19 +15,24 @@ ps.registerCallback(resourceName .. ':server:getBulletinPosts', function(source) local ok, posts = pcall(MySQL.query.await, [[ SELECT - id, title, content, author, author_rank, - category, priority, pinned, created_by, - created_at, updated_at - FROM mdt_bulletin_posts - WHERE job = ? - ORDER BY pinned DESC, FIELD(priority, 'urgent', 'high', 'normal', 'low'), created_at DESC + bp.id, bp.title, bp.content, bp.author, bp.author_rank, + bp.category, bp.priority, bp.pinned, bp.created_by, + bp.created_at, bp.updated_at, + COALESCE(bc.label, bp.category) AS category_label, + COALESCE(bc.color, '#6B7280') AS category_color + FROM mdt_bulletin_posts bp + LEFT JOIN mdt_bulletin_categories bc + ON bc.value = bp.category AND bc.job = bp.job + WHERE bp.job = ? + ORDER BY bp.pinned DESC, + FIELD(bp.priority, 'urgent', 'high', 'normal', 'low'), + bp.created_at DESC ]], { jobName }) if not ok or not posts then return {} end - -- Convert tinyint pinned → boolean for JSON for _, post in ipairs(posts) do - post.pinned = post.pinned == 1 or post.pinned == "1" or post.pinned == true + post.pinned = post.pinned == 1 or post.pinned == '1' or post.pinned == true end return posts @@ -40,23 +49,34 @@ ps.registerCallback(resourceName .. ':server:createBulletinPost', function(sourc data = data or {} - local VALID_CATEGORIES = { announcement = true, operations = true, training = true, general = true, warrants = true } - local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true } + -- Validate category exists for this job (or allow any stored value) + local jobName = ps.getJobName(src) + local jobRank = ps.getJobGradeName(src) + local citizenId = ps.getIdentifier(src) local title = tostring(data.title or ''):gsub('^%s+', ''):gsub('%s+$', '') if title == '' then return { success = false, error = 'Title is required' } end - if not VALID_CATEGORIES[data.category] then return { success = false, error = 'Invalid category' } end - if not VALID_PRIORITIES[data.priority] then return { success = false, error = 'Invalid priority' } end - local jobName = ps.getJobName(src) - local jobRank = ps.getJobGradeName(src) - local citizenId = ps.getIdentifier(src) + local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true } + if not VALID_PRIORITIES[data.priority] then + return { success = false, error = 'Invalid priority' } + end - -- Resolve author name + rank from profile - local profile = MySQL.single.await('SELECT fullname FROM mdt_profiles WHERE citizenid = ?', { citizenId }) - local author = (profile and profile.fullname) or tostring(GetPlayerName(src) or 'Unknown') + -- Check category exists for job + local catRow = MySQL.single.await( + 'SELECT value FROM mdt_bulletin_categories WHERE value = ? AND job = ?', + { data.category, jobName } + ) + if not catRow then + return { success = false, error = 'Invalid or unknown category' } + end + + local profile = MySQL.single.await( + 'SELECT fullname FROM mdt_profiles WHERE citizenid = ?', + { citizenId } + ) + local author = (profile and profile.fullname) or tostring(GetPlayerName(src) or 'Unknown') - -- Only supervisors may pin local canPin = CheckPermission(src, 'bulletin_pin') local pinned = (canPin and data.pinned == true) and 1 or 0 @@ -90,19 +110,17 @@ ps.registerCallback(resourceName .. ':server:updateBulletinPost', function(sourc updates = updates or {} if not postId then return { success = false, error = 'Invalid post id' } end - -- Fetch post to verify ownership / permission local existing = MySQL.single.await( 'SELECT created_by, job FROM mdt_bulletin_posts WHERE id = ?', { postId } ) if not existing then return { success = false, error = 'Post not found' } end - local jobName = ps.getJobName(src) - local citizenId = ps.getIdentifier(src) + local jobName = ps.getJobName(src) + local citizenId = ps.getIdentifier(src) local isSupervisor = CheckPermission(src, 'bulletin_post') local isOwner = existing.created_by == citizenId - -- Authors can edit their own posts; supervisors can edit any post in the same department if not isOwner and not isSupervisor then return { success = false, error = 'No permission to edit this post' } end @@ -110,8 +128,7 @@ ps.registerCallback(resourceName .. ':server:updateBulletinPost', function(sourc return { success = false, error = 'Post belongs to a different department' } end - local VALID_CATEGORIES = { announcement = true, operations = true, training = true, general = true, warrants = true } - local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true } + local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true } local sets = {} local vals = {} @@ -127,15 +144,21 @@ ps.registerCallback(resourceName .. ':server:updateBulletinPost', function(sourc sets[#sets + 1] = 'content = ?' vals[#vals + 1] = tostring(updates.content):sub(1, 65535) end - if updates.category ~= nil and VALID_CATEGORIES[updates.category] then - sets[#sets + 1] = 'category = ?' - vals[#vals + 1] = updates.category + if updates.category ~= nil then + -- Validate category belongs to this job + local catRow = MySQL.single.await( + 'SELECT value FROM mdt_bulletin_categories WHERE value = ? AND job = ?', + { updates.category, jobName } + ) + if catRow then + sets[#sets + 1] = 'category = ?' + vals[#vals + 1] = updates.category + end end if updates.priority ~= nil and VALID_PRIORITIES[updates.priority] then sets[#sets + 1] = 'priority = ?' vals[#vals + 1] = updates.priority end - -- Only supervisors may change pin state if updates.pinned ~= nil and isSupervisor then sets[#sets + 1] = 'pinned = ?' vals[#vals + 1] = updates.pinned and 1 or 0 @@ -144,11 +167,14 @@ ps.registerCallback(resourceName .. ':server:updateBulletinPost', function(sourc if #sets == 0 then return { success = false, error = 'No valid fields to update' } end vals[#vals + 1] = postId - MySQL.update.await('UPDATE mdt_bulletin_posts SET ' .. table.concat(sets, ', ') .. ' WHERE id = ?', vals) + MySQL.update.await( + 'UPDATE mdt_bulletin_posts SET ' .. table.concat(sets, ', ') .. ' WHERE id = ?', + vals + ) return { success = true } end) --- ── Delete a bulletin post (soft delete) ───────────────────── +-- ── Delete a bulletin post ──────────────────────────────────── ps.registerCallback(resourceName .. ':server:deleteBulletinPost', function(source, postId) local src = source @@ -183,7 +209,6 @@ end) ps.registerCallback(resourceName .. ':server:toggleBulletinPin', function(source, postId) local src = source - local newPinned = 0 if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end if not CheckPermission(src, 'bulletin_pin') then return { success = false, error = 'No permission to pin posts' } @@ -203,16 +228,275 @@ ps.registerCallback(resourceName .. ':server:toggleBulletinPin', function(source return { success = false, error = 'Post belongs to a different department' } end - local isPinned = existing.pinned == 1 or existing.pinned == "1" or existing.pinned == true - - if not isPinned then - newPinned = 1 - end - + local isPinned = existing.pinned == 1 or existing.pinned == '1' or existing.pinned == true + local newPinned = isPinned and 0 or 1 + MySQL.update.await( 'UPDATE mdt_bulletin_posts SET pinned = ? WHERE id = ?', { newPinned, postId } ) - return { success = true, pinned = newPinned } +end) + +-- ════════════════════════════════════════════════════════════ +-- Category Management (one row per category) +-- +-- Table: mdt_bulletin_categories +-- Columns: id, job, value, label, icon, color, sort_order, is_default +-- ════════════════════════════════════════════════════════════ + +-- Slugify a label into a safe DB value (e.g. "My Custom Cat" → "my_custom_cat") +local function slugify(str) + return tostring(str) + :lower() + :gsub('%s+', '_') + :gsub('[^%w_]', '') + :sub(1, 48) +end + +-- Seed default categories for a job if none exist yet +local function ensureDefaultCategories(jobName) + local count = MySQL.scalar.await( + 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?', + { jobName } + ) + if (count or 0) > 0 then return end + + local defaults = { + { value = 'announcement', label = 'Announcements', icon = 'campaign', color = '#3B82F6', sort_order = 1 }, + { value = 'operations', label = 'Operations', icon = 'local_police', color = '#8B5CF6', sort_order = 2 }, + { value = 'training', label = 'Training', icon = 'school', color = '#10B981', sort_order = 3 }, + { value = 'general', label = 'General', icon = 'forum', color = '#6B7280', sort_order = 4 }, + } + + for _, cat in ipairs(defaults) do + MySQL.insert.await([[ + INSERT IGNORE INTO mdt_bulletin_categories + (job, value, label, icon, color, sort_order, is_default) + VALUES (?, ?, ?, ?, ?, ?, 1) + ]], { jobName, cat.value, cat.label, cat.icon, cat.color, cat.sort_order }) + end +end + +-- ── GET categories ──────────────────────────────────────────── + +ps.registerCallback(resourceName .. ':server:getBulletinCategories', function(source) + local src = source + if not CheckAuth(src) then return {} end + + local jobName = ps.getJobName(src) + if not jobName or jobName == '' then return {} end + + ensureDefaultCategories(jobName) + + local ok, cats = pcall(MySQL.query.await, [[ + SELECT value, label, icon, color, sort_order, is_default + FROM mdt_bulletin_categories + WHERE job = ? + ORDER BY sort_order ASC, id ASC + ]], { jobName }) + + if not ok or not cats then return {} end + + for _, c in ipairs(cats) do + c.is_default = c.is_default == 1 or c.is_default == '1' or c.is_default == true + end + + return cats +end) + +-- ── ADD category ────────────────────────────────────────────── + +ps.registerCallback(resourceName .. ':server:addBulletinCategory', function(source, data) + local src = source + if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end + if not CheckPermission(src, 'bulletin_post') then + return { success = false, error = 'No permission to manage categories' } + end + + data = data or {} + local jobName = ps.getJobName(src) + + local label = tostring(data.label or ''):gsub('^%s+', ''):gsub('%s+$', '') + if label == '' then return { success = false, error = 'Label is required' } end + + -- Build value from label if not provided + local value = (data.value and data.value ~= '') and slugify(data.value) or slugify(label) + if value == '' then return { success = false, error = 'Could not generate a valid category key' } end + + -- Check for duplicate + local existing = MySQL.scalar.await( + 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ? AND value = ?', + { jobName, value } + ) + if (existing or 0) > 0 then + return { success = false, error = 'A category with that key already exists' } + end + + -- Max 20 categories per job + local total = MySQL.scalar.await( + 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?', + { jobName } + ) + if (total or 0) >= 20 then + return { success = false, error = 'Maximum of 20 categories per department reached' } + end + + local nextOrder = MySQL.scalar.await( + 'SELECT COALESCE(MAX(sort_order), 0) + 1 FROM mdt_bulletin_categories WHERE job = ?', + { jobName } + ) + + local id = MySQL.insert.await([[ + INSERT INTO mdt_bulletin_categories (job, value, label, icon, color, sort_order, is_default) + VALUES (?, ?, ?, ?, ?, ?, 0) + ]], { + jobName, + value, + label:sub(1, 48), + tostring(data.icon or 'label'):sub(1, 48), + tostring(data.color or '#6B7280'):sub(1, 7), + nextOrder + }) + + if not id then return { success = false, error = 'Database error' } end + return { success = true, value = value, id = id } +end) + +-- ── UPDATE category ─────────────────────────────────────────── + +ps.registerCallback(resourceName .. ':server:updateBulletinCategory', function(source, data) + local src = source + if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end + if not CheckPermission(src, 'bulletin_post') then + return { success = false, error = 'No permission to manage categories' } + end + + data = data or {} + local jobName = ps.getJobName(src) + + local existing = MySQL.single.await( + 'SELECT id, is_default FROM mdt_bulletin_categories WHERE job = ? AND value = ?', + { jobName, data.value } + ) + if not existing then return { success = false, error = 'Category not found' } end + + local sets = {} + local vals = {} + + if data.label ~= nil then + local l = tostring(data.label):gsub('^%s+', ''):gsub('%s+$', '') + if l ~= '' then + sets[#sets + 1] = 'label = ?' + vals[#vals + 1] = l:sub(1, 48) + end + end + if data.icon ~= nil then + sets[#sets + 1] = 'icon = ?' + vals[#vals + 1] = tostring(data.icon):sub(1, 48) + end + if data.color ~= nil then + -- Validate hex color + local color = tostring(data.color) + if color:match('^#%x%x%x%x%x%x$') or color:match('^#%x%x%x$') then + sets[#sets + 1] = 'color = ?' + vals[#vals + 1] = color + end + end + + if #sets == 0 then return { success = false, error = 'No valid fields to update' } end + + vals[#vals + 1] = jobName + vals[#vals + 1] = data.value + + MySQL.update.await( + 'UPDATE mdt_bulletin_categories SET ' .. table.concat(sets, ', ') .. + ' WHERE job = ? AND value = ?', + vals + ) + return { success = true } +end) + +-- ── REMOVE category ─────────────────────────────────────────── + +ps.registerCallback(resourceName .. ':server:removeBulletinCategory', function(source, data) + local src = source + if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end + if not CheckPermission(src, 'bulletin_post') then + return { success = false, error = 'No permission to manage categories' } + end + + -- Accept either a table { value = '...' } or a plain string (backwards compat) + local value = type(data) == 'table' and tostring(data.value or '') or tostring(data or '') + local jobName = ps.getJobName(src) + + if value == '' then return { success = false, error = 'Missing category value' } end + + local existing = MySQL.single.await( + 'SELECT id, is_default FROM mdt_bulletin_categories WHERE job = ? AND value = ?', + { jobName, value } + ) + if not existing then return { success = false, error = 'Category not found' } end + + -- Count remaining categories — keep at least 1 + local total = MySQL.scalar.await( + 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?', + { jobName } + ) + if (total or 0) <= 1 then + return { success = false, error = 'Cannot remove the last category' } + end + + -- Reassign posts in this category to 'general' (or the first remaining category) + local fallback = MySQL.single.await( + 'SELECT value FROM mdt_bulletin_categories WHERE job = ? AND value != ? ORDER BY sort_order ASC LIMIT 1', + { jobName, value } + ) + if fallback then + MySQL.update.await( + 'UPDATE mdt_bulletin_posts SET category = ? WHERE job = ? AND category = ?', + { fallback.value, jobName, value } + ) + end + + MySQL.update.await( + 'DELETE FROM mdt_bulletin_categories WHERE job = ? AND value = ?', + { jobName, value } + ) + return { success = true } +end) + +-- ── REORDER categories ──────────────────────────────────────── + +ps.registerCallback(resourceName .. ':server:reorderBulletinCategories', function(source, data) + local src = source + if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end + if not CheckPermission(src, 'bulletin_post') then + return { success = false, error = 'No permission to manage categories' } + end + + local jobName = ps.getJobName(src) + + -- data may arrive as the array directly, or wrapped: { order = [...] } + local order = data + if type(data) == 'table' and data.order then + order = data.order + end + + if type(order) ~= 'table' then + return { success = false, error = 'Invalid order data' } + end + + for i, item in ipairs(order) do + local val = item.value and tostring(item.value) or nil + local sord = item.sort_order and tonumber(item.sort_order) or i + if val then + MySQL.update.await( + 'UPDATE mdt_bulletin_categories SET sort_order = ? WHERE job = ? AND value = ?', + { sord, jobName, val } + ) + end + end + + return { success = true } end) \ No newline at end of file diff --git a/server/backend/dashboard.lua b/server/backend/dashboard.lua index 55239584..86225fdc 100644 --- a/server/backend/dashboard.lua +++ b/server/backend/dashboard.lua @@ -1,15 +1,5 @@ - local resourceName = tostring(GetCurrentResourceName()) -local DEFAULT_CATEGORIES = { - { value = 'announcement', label = 'Announcements', icon = 'campaign' }, - { value = 'operations', label = 'Operations', icon = 'local_police' }, - { value = 'training', label = 'Training', icon = 'school' }, - { value = 'general', label = 'General', icon = 'forum' }, -} - -local SETTINGS_KEY = 'bulletin_categories' - local function getEffectiveJobType(src) local jobType = ps.getJobType(src) local jobName = ps.getJobName(src) @@ -26,8 +16,8 @@ ps.registerCallback(resourceName .. ':server:getJobData', function(source) local src = source assert(src, 'Player ID cannot be nil') local response = { - rank = ps.getJobGradeName(src) or "Officer", - payRate = "$" .. (ps.getJobGradePay(src) or 300) .. "/hr", + rank = ps.getJobGradeName(src) or 'Officer', + payRate = '$' .. (ps.getJobGradePay(src) or 300) .. '/hr', } return response end) @@ -45,25 +35,13 @@ ps.registerCallback(resourceName .. ':server:getReportStatistics', function(sour ]], {}) local row = response and response[1] or { totalThisWeek = 0, totalLastWeek = 0 } - local reportStatistics = { - totalThisWeek = tonumber(row.totalThisWeek) or 0, - changeFromLastWeek = (tonumber(row.totalThisWeek) or 0) - (tonumber(row.totalLastWeek) or 0) + return { + totalThisWeek = tonumber(row.totalThisWeek) or 0, + changeFromLastWeek = (tonumber(row.totalThisWeek) or 0) - (tonumber(row.totalLastWeek) or 0), } - return reportStatistics end) end) -local function parseDateOnly(value) - if not value then - return nil - end - local year, month, day = tostring(value):match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$') - if not year then - return nil - end - return os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = 0 }) -end - ps.registerCallback(resourceName .. ':server:getTimeStatistics', function(source) local src = source assert(src, 'Player ID cannot be nil') @@ -87,16 +65,14 @@ ps.registerCallback(resourceName .. ':server:getTimeStatistics', function(source local result = {} for i = 6, 0, -1 do - local dayTs = os.time() - (i * 24 * 60 * 60) + local dayTs = os.time() - (i * 24 * 60 * 60) local dayKey = os.date('%Y-%m-%d', dayTs) - local label = os.date('%a', dayTs) - local seconds = secondsByDay[dayKey] or 0 + local label = os.date('%a', dayTs) result[#result + 1] = { - day = label, - hours = math.floor((seconds / 3600) * 10) / 10 + day = label, + hours = math.floor(((secondsByDay[dayKey] or 0) / 3600) * 10) / 10, } end - return result end) @@ -150,26 +126,16 @@ ps.registerCallback(resourceName .. ':server:deleteBulletin', function(source, p end) ps.registerCallback(resourceName .. ':server:getRecentReports', function(source, page, limit) - local src = source + local src = source assert(src, 'Player ID cannot be nil') - local pageNumber = tonumber(page) or 1 - if pageNumber < 1 then - pageNumber = 1 - end - - local pageSize = tonumber(limit) or 10 - if pageSize < 1 then - pageSize = 10 - end - if pageSize > 50 then - pageSize = 50 - end + local pageNumber = math.max(1, tonumber(page) or 1) + local pageSize = math.min(50, math.max(1, tonumber(limit) or 10)) local identifier = ps.getIdentifier(src) - local job = ps.getJobName(src) - local jobType = getEffectiveJobType(src) + local job = ps.getJobName(src) + local jobType = getEffectiveJobType(src) + local offset = (pageNumber - 1) * pageSize - local offset = (pageNumber - 1) * pageSize local rows = MySQL.query.await([[ SELECT mr.id, mr.title, mr.type, mr.contentplaintext, mr.author, mr.authorplaintext, mr.datecreated, mr.dateupdated FROM mdt_reports mr @@ -181,8 +147,8 @@ ps.registerCallback(resourceName .. ':server:getRecentReports', function(source, )) OR (mrr.reportid IS NULL AND (? = 'leo' OR ? = 'ems')) OR (mrr.type = 'citizenid' AND mrr.identifier = ?) - OR (mrr.type = 'job' AND mrr.identifier = ?) - OR (mrr.type = 'jobtype' AND mrr.identifier = ?) + OR (mrr.type = 'job' AND mrr.identifier = ?) + OR (mrr.type = 'jobtype' AND mrr.identifier = ?) ) GROUP BY mr.id ORDER BY mr.datecreated DESC @@ -199,15 +165,14 @@ ps.registerCallback(resourceName .. ':server:getActiveBolos', function(source) local BOLOS = MySQL.query.await('SELECT * FROM mdt_bolos WHERE status = ? ORDER BY id DESC', { 'active' }) local result = {} for _, v in pairs(BOLOS or {}) do - local formattedBolo = { - id = v.id, + result[#result + 1] = { + id = v.id, reportId = v.reportId and tostring(v.reportId) or 'N/A', - name = v.subject_name or ps.getPlayerNameByIdentifier(v.subject_id) or 'Unknown', - type = v.type, - notes = v.notes or '', - status = v.status, + name = v.subject_name or ps.getPlayerNameByIdentifier(v.subject_id) or 'Unknown', + type = v.type, + notes = v.notes or '', + status = v.status, } - table.insert(result, formattedBolo) end ps.debug('Fetched ' .. #result .. ' active BOLOs from database for source ' .. src, result) return result @@ -221,32 +186,27 @@ ps.registerCallback(resourceName .. ':server:getActiveUnits', function(source) end) end) --- Sanitize dispatch data for safe serialization through ps.callback --- ps-dispatch objects contain vectors/coords that msgpack can't serialize local function sanitizeDispatch(call) if not call or type(call) ~= 'table' then return nil end - local sanitized = { - id = call.id, - message = call.message or call.dispatchMessage or '', - code = call.code or call.dispatchCode or '', - street = call.street or '', - priority = call.priority or 0, - time = call.time or 0, - gender = call.gender, - plate = call.plate, - color = call.color, - model = call.model, - weapon = call.weapon, - heading = call.heading, - speed = call.speed, - callSign = call.callSign, + id = call.id, + message = call.message or call.dispatchMessage or '', + code = call.code or call.dispatchCode or '', + street = call.street or '', + priority = call.priority or 0, + time = call.time or 0, + gender = call.gender, + plate = call.plate, + color = call.color, + model = call.model, + weapon = call.weapon, + heading = call.heading, + speed = call.speed, + callSign = call.callSign, description = call.description, - camId = call.camId, - firstColor = call.firstColor, + camId = call.camId, + firstColor = call.firstColor, } - - -- Sanitize coords (vectors can't serialize) if call.coords then if type(call.coords) == 'vector3' or type(call.coords) == 'vector4' then sanitized.coords = { x = call.coords.x, y = call.coords.y, z = call.coords.z } @@ -254,30 +214,25 @@ local function sanitizeDispatch(call) sanitized.coords = { x = call.coords.x or call.coords[1], y = call.coords.y or call.coords[2], z = call.coords.z or call.coords[3] } end end - - -- Sanitize units array sanitized.units = {} if call.units and type(call.units) == 'table' then for _, unit in pairs(call.units) do if type(unit) == 'table' then sanitized.units[#sanitized.units + 1] = { citizenid = unit.citizenid, - charinfo = unit.charinfo, - job = unit.job, - metadata = unit.metadata and { callsign = unit.metadata.callsign } or nil, + charinfo = unit.charinfo, + job = unit.job, + metadata = unit.metadata and { callsign = unit.metadata.callsign } or nil, } end end end - - -- Sanitize jobs array if call.jobs and type(call.jobs) == 'table' then sanitized.jobs = {} for _, job in ipairs(call.jobs) do sanitized.jobs[#sanitized.jobs + 1] = job end end - return sanitized end @@ -291,7 +246,6 @@ ps.registerCallback(resourceName .. ':server:getRecentDispatches', function(sour if not ok then return {} end recentDispatches = recentDispatches or {} - -- Filter by job if configured local dispatches = recentDispatches if Config and Config.Dispatch and Config.Dispatch.FilterByJob == true then local jobName = ps.getJobName(src) @@ -302,31 +256,23 @@ ps.registerCallback(resourceName .. ':server:getRecentDispatches', function(sour if not call.jobs or #call.jobs == 0 then filtered[#filtered + 1] = call else - local matched = false for _, job in ipairs(call.jobs) do if job == jobName or job == jobType then - matched = true + filtered[#filtered + 1] = call break end end - if matched then - filtered[#filtered + 1] = call - end end end dispatches = filtered end end - -- Sanitize for serialization local result = {} for _, call in ipairs(dispatches) do local sanitized = sanitizeDispatch(call) - if sanitized then - result[#result + 1] = sanitized - end + if sanitized then result[#result + 1] = sanitized end end - return result end) @@ -335,129 +281,22 @@ ps.registerCallback(resourceName .. ':server:getUsageMetrics', function(source) if not CheckAuth(src) then return {} end return Cache.getOrSet('dashboard:usageMetrics', Config.CacheTTL and Config.CacheTTL.UsageMetrics or 60, function() - -- Use separate queries with pcall to handle missing tables gracefully local function safeCount(query, params) local ok, result = pcall(MySQL.scalar.await, query, params or {}) - if ok then return tonumber(result) or 0 end - return 0 + return ok and (tonumber(result) or 0) or 0 end - - local totalReports = safeCount('SELECT COUNT(*) FROM mdt_reports') - local reportsLast7 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 7 DAY') - local reportsLast30 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 30 DAY') - local totalArrests = safeCount('SELECT COUNT(*) FROM mdt_arrests') - local arrestsLast7 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 7 DAY') - local arrestsLast30 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 30 DAY') - local activeWarrants = safeCount('SELECT COUNT(*) FROM mdt_reports_warrants WHERE expirydate >= NOW()') - return { totals = { - reports = totalReports, - arrests = totalArrests, - activeWarrants = activeWarrants, + reports = safeCount('SELECT COUNT(*) FROM mdt_reports'), + arrests = safeCount('SELECT COUNT(*) FROM mdt_arrests'), + activeWarrants = safeCount('SELECT COUNT(*) FROM mdt_reports_warrants WHERE expirydate >= NOW()'), }, windows = { - reportsLast7 = reportsLast7, - reportsLast30 = reportsLast30, - arrestsLast7 = arrestsLast7, - arrestsLast30 = arrestsLast30, - } + reportsLast7 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 7 DAY'), + reportsLast30 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 30 DAY'), + arrestsLast7 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 7 DAY'), + arrestsLast30 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 30 DAY'), + }, } end) -end) - -local function getSetting(key) - local rows = exports.oxmysql:executeSync( - 'SELECT `value` FROM `mdt_bulletin_settings` WHERE `key` = ? LIMIT 1', - { key } - ) - if rows and rows[1] then - return rows[1].value - end - return nil -end - -local function setSetting(key, value) - exports.oxmysql:executeSync( - [[ - INSERT INTO `mdt_bulletin_settings` (`key`, `value`) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `updated_at` = CURRENT_TIMESTAMP - ]], - { key, value } - ) -end - --- ── CALLBACK: getBulletinCategories ────────────────────────────────────────── - -lib.callback.register('mdt:server:getBulletinCategories', function(source) - local raw = getSetting(SETTINGS_KEY) - - if not raw then - return DEFAULT_CATEGORIES - end - - local ok, decoded = pcall(json.decode, raw) - if not ok or type(decoded) ~= 'table' or #decoded == 0 then - return DEFAULT_CATEGORIES - end - - local sanitized = {} - for _, cat in ipairs(decoded) do - if type(cat.value) == 'string' and type(cat.label) == 'string' and type(cat.icon) == 'string' then - sanitized[#sanitized + 1] = { - value = cat.value, - label = cat.label, - icon = cat.icon, - } - end - end - - if #sanitized == 0 then - return DEFAULT_CATEGORIES - end - - return sanitized -end) - --- ── CALLBACK: saveBulletinCategories ───────────────────────────────────────── - -lib.callback.register('mdt:server:saveBulletinCategories', function(source, categories) - if type(categories) ~= 'table' or #categories == 0 then - return { success = false, message = 'No categories provided' } - end - - local ALLOWED_VALUES = { announcement = true, operations = true, training = true, general = true } - - local clean = {} - for _, cat in ipairs(categories) do - if type(cat.value) ~= 'string' or not ALLOWED_VALUES[cat.value] then - return { success = false, message = ('Unknown category value: %s'):format(tostring(cat.value)) } - end - if type(cat.label) ~= 'string' or #cat.label == 0 or #cat.label > 32 then - return { success = false, message = 'Invalid label for category: ' .. tostring(cat.value) } - end - if type(cat.icon) ~= 'string' or #cat.icon == 0 or #cat.icon > 48 then - return { success = false, message = 'Invalid icon for category: ' .. tostring(cat.value) } - end - clean[#clean + 1] = { - value = cat.value, - label = cat.label, - icon = cat.icon, - } - end - - local ok, encoded = pcall(json.encode, clean) - if not ok then - return { success = false, message = 'JSON encode error' } - end - - local saveOk, err = pcall(setSetting, SETTINGS_KEY, encoded) - if not saveOk then - ps.warn('^1[MDT] bulletin_settings save error: ' .. tostring(err) .. '^0') - return { success = false, message = 'Database error' } - end - - ps.success(('[MDT] Bulletin categories updated by source %d'):format(source)) - return { success = true } end) \ No newline at end of file From 1e546f65381e617e93d365e01e11c114b0bdf44c Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 00:30:54 +0200 Subject: [PATCH 115/144] Add files via upload --- client/backend/bulletinboard.lua | 51 +++++++++++++++++++++++++++++++- client/backend/dashboard.lua | 4 +-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/client/backend/bulletinboard.lua b/client/backend/bulletinboard.lua index d6729213..bbd989e8 100644 --- a/client/backend/bulletinboard.lua +++ b/client/backend/bulletinboard.lua @@ -22,7 +22,7 @@ RegisterNUICallback('updateBulletinPost', function(data, cb) cb(result or { success = false }) end) --- Delete a bulletin post (soft delete) +-- Delete a bulletin post RegisterNUICallback('deleteBulletinPost', function(data, cb) if not data or not data.id then cb({ success = false }) return end local result = ps.callback(resourceName .. ':server:deleteBulletinPost', data.id) @@ -34,4 +34,53 @@ RegisterNUICallback('toggleBulletinPin', function(data, cb) if not data or not data.id then cb({ success = false }) return end local result = ps.callback(resourceName .. ':server:toggleBulletinPin', data.id) cb(result or { success = false }) +end) + +-- ── Category management ─────────────────────────────────────── + +-- Get all categories for the current job +RegisterNUICallback('getBulletinCategories', function(data, cb) + local result = ps.callback(resourceName .. ':server:getBulletinCategories') + cb(result or {}) +end) + +-- Add a new category +RegisterNUICallback('addBulletinCategory', function(data, cb) + if not data or not data.label or data.label == '' then + cb({ success = false, error = 'Missing required fields' }) + return + end + local result = ps.callback(resourceName .. ':server:addBulletinCategory', data) + cb(result or { success = false }) +end) + +-- Update an existing category (label, icon, color, sort_order) +RegisterNUICallback('updateBulletinCategory', function(data, cb) + if not data or not data.value then + cb({ success = false, error = 'Missing category value' }) + return + end + local result = ps.callback(resourceName .. ':server:updateBulletinCategory', data) + cb(result or { success = false }) +end) + +-- Remove a category by value +RegisterNUICallback('removeBulletinCategory', function(data, cb) + if not data or not data.value then + cb({ success = false, error = 'Missing category value' }) + return + end + local result = ps.callback(resourceName .. ':server:removeBulletinCategory', data) + cb(result or { success = false }) +end) + +-- Reorder categories (bulk sort_order update) +RegisterNUICallback('reorderBulletinCategories', function(data, cb) + if not data or not data.order then + cb({ success = false, error = 'Missing order data' }) + return + end + -- Pass the whole data table so server can read data.order reliably + local result = ps.callback(resourceName .. ':server:reorderBulletinCategories', data) + cb(result or { success = false }) end) \ No newline at end of file diff --git a/client/backend/dashboard.lua b/client/backend/dashboard.lua index 9f18165f..3f0d45d2 100644 --- a/client/backend/dashboard.lua +++ b/client/backend/dashboard.lua @@ -175,7 +175,7 @@ RegisterNUICallback('deleteBulletin', function(data, cb) end) RegisterNUICallback('getBulletinCategories', function(_, cb) - local result = lib.callback.await('mdt:server:getBulletinCategories', false) + local result = ps.callback(resourceName .. ':server:getBulletinCategories', false) cb(result or {}) end) @@ -196,7 +196,7 @@ RegisterNUICallback('saveBulletinCategories', function(data, cb) end end - local result = lib.callback.await('mdt:server:saveBulletinCategories', false, data.categories) + local result = ps.callback('mdt:server:saveBulletinCategories', false, data.categories) cb(result or { success = false, message = 'Server error' }) end) From fd6994ca04e62f3a2212c1ef711579335920cde6 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 00:31:24 +0200 Subject: [PATCH 116/144] Add files via upload --- web/src/constants/nuiEvents.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/web/src/constants/nuiEvents.ts b/web/src/constants/nuiEvents.ts index d20ba376..b8b3f9e2 100644 --- a/web/src/constants/nuiEvents.ts +++ b/web/src/constants/nuiEvents.ts @@ -302,13 +302,17 @@ export const NUI_EVENTS = { ACKNOWLEDGE_SOP: "acknowledgesSOP", }, BULLETIN: { - GET_POSTS: "getBulletinPosts", - CREATE_POST: "createBulletinPost", - UPDATE_POST: "updateBulletinPost", - DELETE_POST: "deleteBulletinPost", - TOGGLE_PIN: "toggleBulletinPin", - GET_CATEGORIES: 'getBulletinCategories', - SAVE_CATEGORIES: 'saveBulletinCategories', + GET_POSTS: 'getBulletinPosts', + CREATE_POST: 'createBulletinPost', + UPDATE_POST: 'updateBulletinPost', + DELETE_POST: 'deleteBulletinPost', + TOGGLE_PIN: 'toggleBulletinPin', + GET_CATEGORIES: 'getBulletinCategories', + ADD_CATEGORY: 'addBulletinCategory', + UPDATE_CATEGORY: 'updateBulletinCategory', + REMOVE_CATEGORY: 'removeBulletinCategory', + REORDER_CATEGORIES: 'reorderBulletinCategories', + SAVE_CATEGORIES: 'updateBulletinCategory', }, } as const; From e6e00697a075b89de355dee8637d5afeca9d0e84 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 00:31:51 +0200 Subject: [PATCH 117/144] Add files via upload --- web/src/pages/BulletInBoard.svelte | 725 +++++++++++------------------ 1 file changed, 282 insertions(+), 443 deletions(-) diff --git a/web/src/pages/BulletInBoard.svelte b/web/src/pages/BulletInBoard.svelte index 2f01ec71..375da161 100644 --- a/web/src/pages/BulletInBoard.svelte +++ b/web/src/pages/BulletInBoard.svelte @@ -12,7 +12,9 @@ content: string; author: string; author_rank: string; - category: BulletinCategory; + category: string; + category_label?: string; + category_color?: string; priority: BulletinPriority; pinned: boolean; created_by: string; @@ -24,9 +26,11 @@ value: string; label: string; icon: string; + color: string; + sort_order?: number; + is_default?: boolean; } - type BulletinCategory = 'announcement' | 'operations' | 'training' | 'general'; type BulletinPriority = 'low' | 'normal' | 'high' | 'urgent'; interface ModalState { @@ -39,14 +43,9 @@ // ── Constants ────────────────────────────────────────────── - const STATIC_ALL_CATEGORY: SidebarCategory = { label: 'All Posts', icon: 'dashboard', value: 'all' }; - - const DEFAULT_DYNAMIC_CATEGORIES: SidebarCategory[] = [ - { label: 'Announcements', icon: 'campaign', value: 'announcement' }, - { label: 'Operations', icon: 'local_police', value: 'operations' }, - { label: 'Training', icon: 'school', value: 'training' }, - { label: 'General', icon: 'forum', value: 'general' }, - ]; + const STATIC_ALL_CATEGORY: SidebarCategory = { + label: 'All Posts', icon: 'dashboard', value: 'all', color: '#6B7280' + }; const PRIORITY_META: Record = { low: { label: 'Low', icon: 'arrow_downward', color: 'rgba(100,200,100,0.80)' }, @@ -58,14 +57,13 @@ // ── State ────────────────────────────────────────────────── let posts = $state([]); + let categories = $state([]); let loading = $state(true); let saving = $state(false); let searchQuery = $state(''); let activeCategory = $state('all'); let expandedId = $state(null); - let dynamicCategories = $state(DEFAULT_DYNAMIC_CATEGORIES); - let modal = $state({ open: false, mode: 'create', @@ -79,10 +77,10 @@ // ── Derived ──────────────────────────────────────────────── - let sidebarCategories = $derived([STATIC_ALL_CATEGORY, ...dynamicCategories]); + let sidebarCategories = $derived([STATIC_ALL_CATEGORY, ...categories]); - let categoryLabels = $derived( - Object.fromEntries(dynamicCategories.map(c => [c.value, c.label])) + let activeCatMeta = $derived( + sidebarCategories.find(c => c.value === activeCategory) ?? STATIC_ALL_CATEGORY ); let pinnedPosts = $derived( @@ -115,7 +113,8 @@ // ── Helpers ──────────────────────────────────────────────── function defaultPost(): Partial { - return { title: '', content: '', category: 'general', priority: 'normal', pinned: false }; + const firstCat = categories[0]?.value ?? 'general'; + return { title: '', content: '', category: firstCat, priority: 'normal', pinned: false }; } function matchesSearch(p: BulletinPost): boolean { @@ -137,7 +136,16 @@ } catch { return dateStr; } } - function canEdit(post: BulletinPost): boolean { + function getCategoryMeta(value: string): SidebarCategory { + return categories.find(c => c.value === value) ?? { + value, + label: value, + icon: 'label', + color: '#6B7280', + }; + } + + function canEdit(_post: BulletinPost): boolean { return authService?.hasPermission?.('bulletin_post') || false; } @@ -152,17 +160,18 @@ // ── Data loading ─────────────────────────────────────────── onMount(async () => { - await Promise.all([loadPosts(), loadCategories()]); + await loadCategories(); + await loadPosts(); }); async function loadCategories() { try { const result = await fetchNui(NUI_EVENTS.BULLETIN.GET_CATEGORIES, {}, []); if (Array.isArray(result) && result.length > 0) { - dynamicCategories = result; + categories = result; } } catch { - // keep defaults + // keep empty, posts still load fine } } @@ -253,18 +262,36 @@
{#each sidebarCategories as cat} + {@const count = postCountFor(cat.value)} {/each}
@@ -281,6 +308,20 @@
+ + + {#if activeCategory !== 'all'} +
+ {activeCatMeta.icon} + {activeCatMeta.label} + · + {postCountFor(activeCategory)} post{postCountFor(activeCategory) !== 1 ? 's' : ''} +
+ {/if} + {#if loading}
@@ -304,7 +345,9 @@
{#each pinnedPosts as post (post.id)} -
+ {@const catMeta = getCategoryMeta(post.category)} +
@@ -329,8 +372,14 @@ {post.author_rank} {/if} · - label - {categoryLabels[post.category] ?? post.category} + + + {catMeta.icon} + {post.category_label ?? catMeta.label} + · schedule {formatDate(post.created_at)} @@ -372,7 +421,12 @@
{#each regularPosts() as post (post.id)} -
+ {@const catMeta = getCategoryMeta(post.category)} +
+ +
+
{#if isLoading} -
-
-

Loading bulletins...

-
+

Loading bulletins...

{:else}
{#each bulletins as bulletin (bulletin.id || bulletin.content)} @@ -266,12 +333,10 @@ {/if}
{#if bulletin.id} - {/if}
@@ -282,68 +347,159 @@ {/if}
- +
- Bulletin Board Categories -
- - -
+ + Bulletin Board Categories + {categories.length}/20 + +

- Customize the category labels and icons shown in the Bulletin Board sidebar. - Use any Material Icon name. + Drag to reorder  ·  Click icon circle to change icon  ·  + Click color dot to pick color  ·  + Icon reference

+ + {#if showAddForm} +
+
+
+ + +
+
+ {newCatIcon || 'help_outline'} +
+ + e.key === 'Enter' && addCategory()} /> + {#if newCatLabel.trim()} + {slugify(newCatLabel)} + {/if} +
+ +
+ {#each ICON_SUGGESTIONS as icon} + + {/each} +
+ +
+ + +
+
+ {/if} + + {#if categoriesLoading} -
-
-

Loading categories...

+
+

Loading categories...

{:else} -
- {#each categories as cat} -
- -
- {cat.icon || 'help_outline'} +
+ {#each categories as cat, idx (cat.value)} +
+ +
onHandleMouseDown(e, idx)}>⠿
+ + +
+ +
- + + + +
Label - +
- -
- Icon name - + +
+ Icon +
- -
{cat.value}
+ +
{cat.value}
+ + {#if cat.is_default} + default + {/if} + + + + + +
+ + + {#if showIconPicker === cat.value} +
+ {#each ICON_SUGGESTIONS as icon} + + {/each} +
+ {/if} {/each}
{/if} @@ -351,400 +507,132 @@
\ No newline at end of file From 0b31d2dd20ac09ce13a01cd4ca625bd950659430 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 18:01:31 +0200 Subject: [PATCH 119/144] callsign missing warning callsign missing warning --- client/backend/dashboard.lua | 3 +++ client/backend/officers.lua | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/client/backend/dashboard.lua b/client/backend/dashboard.lua index 3f0d45d2..e3319fc5 100644 --- a/client/backend/dashboard.lua +++ b/client/backend/dashboard.lua @@ -60,6 +60,9 @@ function NUIUpdateAuth() citizenid = playerData.citizenid, job = playerData.job, charinfo = playerData.charinfo, + metadata = type(playerData.metadata) == 'table' and { + callsign = playerData.metadata.callsign or '', + } or nil, } or nil, isLEO = isAuthorized, onDuty = ps.getJobDuty() or false, diff --git a/client/backend/officers.lua b/client/backend/officers.lua index 7fb7a99f..448bbe3d 100644 --- a/client/backend/officers.lua +++ b/client/backend/officers.lua @@ -6,12 +6,10 @@ RegisterNUICallback('setCallsign', function(data, cb) cb({ success = false, message = 'MDT is not open' }) return end - if type(data) ~= 'table' or (not data.cid and not data.citizenid) or (not data.newcallsign and not data.callsign) then cb({ success = false, message = 'Missing citizen ID or callsign' }) return end - local result = ps.callback(resourceName .. ':server:setCallsign', { citizenid = data.cid or data.citizenid, callsign = data.newcallsign or data.callsign, @@ -19,6 +17,16 @@ RegisterNUICallback('setCallsign', function(data, cb) cb(result or { success = false, message = 'Failed to set callsign' }) end) +RegisterNUICallback('getCallsign', function(data, cb) + print("test 1") + if not MDTOpen then cb({ callsign = '' }) return end + print("test 2") + local result = ps.callback(resourceName .. ':server:getCallsign', { + citizenid = data.citizenid, + }) + cb(result or { callsign = '' }) +end) + -- Set Radio Frequency RegisterNUICallback('setRadio', function(data, cb) if not MDTOpen then From 0bdb0333d5e50b5e85047f4df014ad3dbac9068c Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 18:01:59 +0200 Subject: [PATCH 120/144] callsign missing warning callsign missing warning --- server/backend/officers.lua | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/server/backend/officers.lua b/server/backend/officers.lua index 60bb9d60..4d29fc3b 100644 --- a/server/backend/officers.lua +++ b/server/backend/officers.lua @@ -17,7 +17,6 @@ end) ps.registerCallback(resourceName .. ':server:setCallsign', function(source, payload) local src = source if not CheckAuth(src) then return { success = false, message = 'Unauthorized' } end - payload = payload or {} local cid = payload.citizenid or payload.cid local newCallsign = payload.callsign or payload.newcallsign @@ -25,7 +24,6 @@ ps.registerCallback(resourceName .. ':server:setCallsign', function(source, payl if not cid or not newCallsign then return { success = false, message = 'Missing citizen ID or callsign' } end - if not QBCore then return { success = false, message = 'Core framework not available' } end local Player = QBCore.Functions.GetPlayerByCitizenId(cid) if Player then @@ -40,10 +38,29 @@ ps.registerCallback(resourceName .. ':server:setCallsign', function(source, payl return { success = true, message = 'Callsign updated to ' .. newCallsign } end - + print("test bestanden 4") return { success = false, message = 'Player must be online to update callsign' } end) +ps.registerCallback(resourceName .. ':server:getCallsign', function(source, payload) + print("test bestanden 1") + if not CheckAuth(source) then return { callsign = '' } end + print("test bestanden 2") + local cid = payload.citizenid + if not cid then return { callsign = '' } end + print("test bestanden 3") + + if not QBCore then return { success = false, message = 'Core framework not available' } end + print("test bestanden 4") + local Player = QBCore.Functions.GetPlayerByCitizenId(cid) + if Player then + return { callsign = Player.PlayerData.metadata.callsign or '' } + end + print("test bestanden 5") + local row = MySQL.single.await('SELECT callsign FROM mdt_profiles WHERE citizenid = ?', { cid }) + return { callsign = row and row.callsign or '' } +end) + -- Set Radio Frequency ps.registerCallback(resourceName .. ':server:setRadio', function(source, payload) local src = source From a979a0d9c7ad1cee79a5d923f66ef36477a8e92f Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 18:02:27 +0200 Subject: [PATCH 121/144] callsign missing warning callsign missing warning --- web/src/pages/Dashboard.svelte | 913 ++++++++++++--------------------- 1 file changed, 322 insertions(+), 591 deletions(-) diff --git a/web/src/pages/Dashboard.svelte b/web/src/pages/Dashboard.svelte index fd641e26..1276093c 100644 --- a/web/src/pages/Dashboard.svelte +++ b/web/src/pages/Dashboard.svelte @@ -10,6 +10,7 @@ import { createDispatchService } from "../services/dispatchService.svelte"; import ReportItem from "../components/dashboard/ReportItem.svelte"; import type { PlayerData } from "@/interfaces/IPlayerData"; + interface ActiveBolo { id: number; reportId: string; @@ -52,7 +53,7 @@ dojCourtOrders = Array.isArray(orders) ? orders.slice(0, 5) : []; } - // Create services + // Services const dashboardService = createDashboardService() as ReturnType< typeof createDashboardService > & { @@ -62,55 +63,119 @@ }; const dispatchService = createDispatchService(); - // UI state (keep in component since it's view-specific) + // UI state let expandedDispatch: string | null = $state(null); let reportOpened: number | null = $state(null); - let warrantOpened: number | null = $state(null); - // Pagination for warrants and BOLOs + // Pagination const PAGE_SIZE = 10; let warrantPage = $state(0); let boloPage = $state(0); let warrantTotalPages = $derived(Math.max(1, Math.ceil(dashboardService.activeWarrants.length / PAGE_SIZE))); let boloTotalPages = $derived(Math.max(1, Math.ceil((dashboardService.activeBolos || []).length / PAGE_SIZE))); - let pagedWarrants = $derived(dashboardService.activeWarrants.slice(warrantPage * PAGE_SIZE, (warrantPage + 1) * PAGE_SIZE)); let pagedBolos = $derived((dashboardService.activeBolos || []).slice(boloPage * PAGE_SIZE, (boloPage + 1) * PAGE_SIZE)); - onMount(() => { + // ── Callsign ── + const EMPTY_CALLSIGN_VALUES = new Set(['', 'NIL', 'NO CALLSIGN', 'NONE', 'NULL']); + + function isValidCallsign(cs: string | null | undefined): boolean { + if (!cs) return false; + const upper = cs.trim().toUpperCase(); + if (EMPTY_CALLSIGN_VALUES.has(upper)) return false; + if (!upper.startsWith('PD-')) return false; + return upper.length > 3; + } + + let callsignModalOpen = $state(false); + let callsignInput = $state(""); + let callsignSaving = $state(false); + let callsignLoading = $state(true); + let localCallsign = $state(""); + + async function fetchCallsign() { + callsignLoading = true; + const result = await fetchNui<{ callsign?: string }>( + NUI_EVENTS.DASHBOARD.GET_CALLSIGN, + { citizenid: playerData?.citizenid }, + { callsign: '' } + ); + const cs = result?.callsign?.trim() ?? ""; + if (isValidCallsign(cs)) { + localCallsign = cs; + } + callsignLoading = false; + } + + let hasCallsign = $derived(isValidCallsign(localCallsign)); + + function openCallsignModal() { + callsignInput = hasCallsign ? localCallsign : "PD-"; + callsignModalOpen = true; + } + + function closeCallsignModal() { + callsignModalOpen = false; + callsignInput = ""; + } + + function handleCallsignInput(e: Event) { + let val = (e.target as HTMLInputElement).value + .toUpperCase() + .replace(/[^A-Z0-9\-]/g, ""); + + if (!val.startsWith('PD-')) { + if (val.startsWith('PD')) { + val = 'PD-' + val.slice(2); + } else if (val.startsWith('P')) { + val = 'PD-' + val.slice(1).replace(/^D-?/, ''); + } else { + val = 'PD-' + val.replace(/^[PD\-]+/, ''); + } + } + + callsignInput = val.slice(0, 6); + (e.target as HTMLInputElement).value = callsignInput; + } + + async function saveCallsign() { + const cs = callsignInput.trim(); + if (!isValidCallsign(cs) || callsignSaving) return; + callsignSaving = true; + try { + await fetchNui(NUI_EVENTS.DASHBOARD.SET_CALLSIGN, { callsign: cs, citizenid: playerData?.citizenid }); + localCallsign = cs; + closeCallsignModal(); + } catch { + /* silent */ + } finally { + callsignSaving = false; + } + } + + onMount(async () => { dashboardService.initialize(); dashboardService.startCarouselTimer(); loadDojDashboard(); + await fetchCallsign(); }); onDestroy(() => { dashboardService.destroy(); }); - function viewWarrant(warrantId: string) { - tabService.setActiveTab("Warrants"); - const activeInstance = tabService.getActiveInstance(); - if (activeInstance) { - tabService.setInstanceTab(activeInstance.id, "Warrants"); - } - } - function viewBolo(boloId: number) { tabService.setActiveTab("BOLOs"); const activeInstance = tabService.getActiveInstance(); - if (activeInstance) { - tabService.setInstanceTab(activeInstance.id, "BOLOs"); - } + if (activeInstance) tabService.setInstanceTab(activeInstance.id, "BOLOs"); } function viewReport(reportId: string) { openReportInEditor(reportId); tabService.setActiveTab("Reports"); const activeInstance = tabService.getActiveInstance(); - if (activeInstance) { - tabService.setInstanceTab(activeInstance.id, "Reports"); - } + if (activeInstance) tabService.setInstanceTab(activeInstance.id, "Reports"); } function toggleDispatch(dispatchId: string) { @@ -133,9 +198,7 @@ function getTimeTranslated(time: number): string { const now = Date.now(); let diffInMs = now - time; - if (diffInMs < 0) return "in the future"; - const units = [ { label: "year", ms: 1000 * 60 * 60 * 24 * 365 }, { label: "month", ms: 1000 * 60 * 60 * 24 * 30 }, @@ -144,9 +207,7 @@ { label: "hour", ms: 1000 * 60 * 60 }, { label: "minute", ms: 1000 * 60 }, ]; - const parts = []; - for (const unit of units) { const count = Math.floor(diffInMs / unit.ms); if (count >= 1) { @@ -154,34 +215,21 @@ diffInMs -= count * unit.ms; } } - - if (parts.length === 0) return "just now"; - - return parts.join(", ") + " ago"; + return parts.length === 0 ? "just now" : parts.join(", ") + " ago"; } async function attachYourselfToDispatch(dispatchId: string) { - const result = - await dispatchService.attachYourselfToDispatch(dispatchId); - if (result) { - dashboardService.setRecentDispatches(result); - } + const result = await dispatchService.attachYourselfToDispatch(dispatchId); + if (result) dashboardService.setRecentDispatches(result); } async function detachYourselfFromDispatch(dispatchId: string) { - const result = - await dispatchService.detachYourselfFromDispatch(dispatchId); - if (result) { - dashboardService.setRecentDispatches(result); - } + const result = await dispatchService.detachYourselfFromDispatch(dispatchId); + if (result) dashboardService.setRecentDispatches(result); } function openReport(id: number) { - if (reportOpened === id) { - reportOpened = null; - } else { - reportOpened = id; - } + reportOpened = reportOpened === id ? null : id; } async function loadMoreReports() { @@ -193,9 +241,7 @@ openReportInEditor(reportId); tabService.setActiveTab("Reports"); const activeInstance = tabService.getActiveInstance(); - if (activeInstance) { - tabService.setInstanceTab(activeInstance.id, "Reports"); - } + if (activeInstance) tabService.setInstanceTab(activeInstance.id, "Reports"); } function getCarouselDotOpacity(index: number): number { @@ -204,12 +250,16 @@ } return 0.3; } + + let currentBulletinContent = $derived( + dashboardService.bulletins[dashboardService.currentBulletinIndex]?.content ?? dashboardService.bulletinContent ?? "" + );
-
+
@@ -220,16 +270,25 @@
+ +
- {dashboardService.reportsInfo.totalThisWeek} 0} class:negative={dashboardService.reportsInfo.changeFromLastWeek < 0}>{#if dashboardService.reportsInfo.changeFromLastWeek > 0}+{/if}{dashboardService.reportsInfo.changeFromLastWeek} + + {dashboardService.reportsInfo.totalThisWeek} + 0} class:negative={dashboardService.reportsInfo.changeFromLastWeek < 0}> + {#if dashboardService.reportsInfo.changeFromLastWeek > 0}+{/if}{dashboardService.reportsInfo.changeFromLastWeek} + + Reports this week
+ +
@@ -239,17 +298,16 @@ Active units
-
- +
{#if dashboardService.bulletins.length > 0} - {dashboardService.bulletins[dashboardService.currentBulletinIndex]?.content || dashboardService.bulletinContent} + {currentBulletinContent} {#if dashboardService.bulletins.length > 1} {/if} + {#if currentBulletinContent} +
{currentBulletinContent}
+ {/if} {:else} No active bulletins {/if}
-
- + + {#if !callsignLoading} + {#if hasCallsign} + + {:else} +
+ + No callsign set + +
+ {/if} +
+ {/if} + +
- {#if expandedDispatch === dispatch.id}
Attached Units
- - +
{#each dispatch.units as unit} - - {dispatchService.getCallSign(unit.metadata?.callsign)} - {unit.charinfo?.firstname} {unit.charinfo?.lastname} - + {dispatchService.getCallSign(unit.metadata?.callsign)} - {unit.charinfo?.firstname} {unit.charinfo?.lastname} {/each}
@@ -511,533 +570,205 @@ {/if}
+ + + {#if callsignModalOpen} + + +
{ if (e.target === e.currentTarget) closeCallsignModal(); }}> + +
+ {/if}
+ /* Pager */ + .pager { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 6px 16px 10px; flex-shrink: 0; } + .pager-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; background: transparent; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 4px; color: rgba(255, 255, 255, 0.35); cursor: pointer; transition: all 0.12s; } + .pager-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.7); } + .pager-btn:disabled { opacity: 0.2; cursor: default; } + .pager-info { font-size: 10px; color: rgba(255, 255, 255, 0.35); font-weight: 500; } + + /* Empty & Load More */ + .empty-state { color: rgba(255, 255, 255, 0.2); font-size: 11px; text-align: center; padding: 24px 0; } + .load-more-btn { background: transparent; color: rgba(255, 255, 255, 0.35); border: none; border-top: 1px solid rgba(255, 255, 255, 0.04); padding: 8px; font-size: 11px; font-weight: 500; cursor: pointer; transition: all 0.12s; text-align: center; flex-shrink: 0; } + .load-more-btn:hover { color: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.02); } + + /* Dispatch */ + .dispatch-item { border-radius: 6px; overflow: hidden; transition: background 0.1s; } + .dispatch-item:hover { background: rgba(255, 255, 255, 0.02); } + .dispatch-item.expanded { background: rgba(255, 255, 255, 0.03); } + .dispatch-btn { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: none; border: none; cursor: pointer; color: inherit; font: inherit; text-align: left; } + .priority-bar { width: 3px; height: 24px; border-radius: 2px; flex-shrink: 0; } + .dispatch-detail { padding: 0 10px 10px 23px; } + .dispatch-detail-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 6px; } + .detail-label { color: rgba(255, 255, 255, 0.3); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } + .dispatch-btns { display: flex; gap: 4px; } + .d-action-btn { background: rgba(var(--accent-rgb), 0.1); color: #60a5fa; border: 1px solid rgba(var(--accent-rgb), 0.15); padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; cursor: pointer; transition: all 0.12s; } + .d-action-btn:hover { background: rgba(var(--accent-rgb), 0.18); border-color: rgba(var(--accent-rgb), 0.3); } + .unit-chips { display: flex; flex-wrap: wrap; gap: 4px; } + .unit-chip { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.5); padding: 2px 7px; border-radius: 3px; font-size: 10px; font-weight: 500; } + + .list-item-btn { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 8px 12px; background: none; border: none; border-bottom: 1px solid rgba(255, 255, 255, 0.04); cursor: pointer; text-align: left; color: inherit; } + .list-item-btn:hover { background: rgba(255, 255, 255, 0.04); } + + /* Callsign Modal */ + .cs-modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 9999; } + .cs-modal { background: var(--card-dark-bg); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 6px; width: min(320px, 92vw); display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); } + .cs-modal-header { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; } + .cs-modal-title { font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.85); } + .cs-modal-current { font-size: 9px; color: rgba(255, 255, 255, 0.25); flex: 1; } + .cs-modal-close { display: flex; align-items: center; justify-content: center; background: transparent; color: rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); padding: 4px; border-radius: 3px; cursor: pointer; transition: all 0.1s; margin-left: auto; } + .cs-modal-close:hover { color: rgba(255, 255, 255, 0.7); border-color: rgba(255, 255, 255, 0.1); } + .cs-modal-body { padding: 14px 16px; display: flex; flex-direction: column; gap: 3px; } + .cs-input-label { color: rgba(255, 255, 255, 0.35); font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; } + .cs-input { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 3px; padding: 5px 8px; color: rgba(255, 255, 255, 0.8); font-size: 13px; font-weight: 600; letter-spacing: 1.5px; font-family: inherit; text-transform: uppercase; width: 100%; box-sizing: border-box; transition: border-color 0.1s; } + .cs-input:focus { outline: none; border-color: rgba(255, 255, 255, 0.1); } + .cs-input::placeholder { color: rgba(255, 255, 255, 0.15); letter-spacing: 1.5px; font-weight: 400; } + .cs-hint { color: rgba(255, 255, 255, 0.2); font-size: 9px; margin-top: 1px; } + .cs-modal-footer { display: flex; justify-content: flex-end; gap: 6px; padding: 10px 16px; border-top: 1px solid rgba(255, 255, 255, 0.06); } + .cs-btn-cancel { background: transparent; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 3px; padding: 4px 10px; color: rgba(255, 255, 255, 0.4); font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } + .cs-btn-cancel:hover:not(:disabled) { color: rgba(255, 255, 255, 0.7); border-color: rgba(255, 255, 255, 0.1); } + .cs-btn-cancel:disabled { opacity: 0.4; cursor: not-allowed; } + .cs-btn-confirm { background: rgba(16, 185, 129, 0.06); color: rgba(52, 211, 153, 0.7); border: 1px solid rgba(16, 185, 129, 0.1); border-radius: 3px; padding: 4px 12px; font-size: 10px; font-weight: 600; cursor: pointer; transition: all 0.1s; } + .cs-btn-confirm:hover:not(:disabled) { background: rgba(16, 185, 129, 0.12); color: rgba(110, 231, 183, 0.9); } + .cs-btn-confirm:disabled { opacity: 0.4; cursor: not-allowed; } + \ No newline at end of file From ada1f2594d51b81ddf35c412f1984feecc2fd312 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 18:02:51 +0200 Subject: [PATCH 122/144] callsign missing warning callsign missing warning --- web/src/constants/nuiEvents.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/constants/nuiEvents.ts b/web/src/constants/nuiEvents.ts index b8b3f9e2..226e7c95 100644 --- a/web/src/constants/nuiEvents.ts +++ b/web/src/constants/nuiEvents.ts @@ -23,6 +23,8 @@ export const NUI_EVENTS = { UPDATE_ACTIVE_UNITS: "updateActiveUnits", UPDATE_RECENT_DISPATCHES: "updateRecentDispatches", UPDATE_USAGE_METRICS: "updateUsageMetrics", + SET_CALLSIGN: "setCallsign", + GET_CALLSIGN: "getCallsign" }, DISPATCH: { ATTACH_TO_DISPATCH: "attachToDispatch", From d40ee5c02308c88919be8c6c0780078fcce4e627 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 18:07:01 +0200 Subject: [PATCH 123/144] callsign missing warning - removed debug prints callsign missing warning - removed debug prints --- client/backend/officers.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/backend/officers.lua b/client/backend/officers.lua index 448bbe3d..16475bb7 100644 --- a/client/backend/officers.lua +++ b/client/backend/officers.lua @@ -18,9 +18,7 @@ RegisterNUICallback('setCallsign', function(data, cb) end) RegisterNUICallback('getCallsign', function(data, cb) - print("test 1") if not MDTOpen then cb({ callsign = '' }) return end - print("test 2") local result = ps.callback(resourceName .. ':server:getCallsign', { citizenid = data.citizenid, }) From 073124e2d8b30c1eebd6ea3a66ff2878409c1837 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 30 May 2026 18:07:14 +0200 Subject: [PATCH 124/144] callsign missing warning - removed debug prints callsign missing warning - removed debug prints --- server/backend/officers.lua | 6 ------ 1 file changed, 6 deletions(-) diff --git a/server/backend/officers.lua b/server/backend/officers.lua index 4d29fc3b..082b02b7 100644 --- a/server/backend/officers.lua +++ b/server/backend/officers.lua @@ -38,25 +38,19 @@ ps.registerCallback(resourceName .. ':server:setCallsign', function(source, payl return { success = true, message = 'Callsign updated to ' .. newCallsign } end - print("test bestanden 4") return { success = false, message = 'Player must be online to update callsign' } end) ps.registerCallback(resourceName .. ':server:getCallsign', function(source, payload) - print("test bestanden 1") if not CheckAuth(source) then return { callsign = '' } end - print("test bestanden 2") local cid = payload.citizenid if not cid then return { callsign = '' } end - print("test bestanden 3") if not QBCore then return { success = false, message = 'Core framework not available' } end - print("test bestanden 4") local Player = QBCore.Functions.GetPlayerByCitizenId(cid) if Player then return { callsign = Player.PlayerData.metadata.callsign or '' } end - print("test bestanden 5") local row = MySQL.single.await('SELECT callsign FROM mdt_profiles WHERE citizenid = ?', { cid }) return { callsign = row and row.callsign or '' } end) From 909ae0129bf5477377ccda8a43ac8002e9483254 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:33:08 +0200 Subject: [PATCH 125/144] fixed activewarrant function error fixed activewarrant function error --- web/src/services/dashboardService.svelte.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/services/dashboardService.svelte.ts b/web/src/services/dashboardService.svelte.ts index 81bdaa51..af4ac01d 100644 --- a/web/src/services/dashboardService.svelte.ts +++ b/web/src/services/dashboardService.svelte.ts @@ -168,7 +168,7 @@ export function createDashboardService() { useNuiEvent( NUI_EVENTS.DASHBOARD.UPDATE_ACTIVE_WARRANTS, (data) => { - activeWarrants = data || activeWarrants; + activeWarrants = Array.isArray(data) ? data : activeWarrants; }, ); @@ -251,8 +251,7 @@ export function createDashboardService() { { key: NUI_EVENTS.DASHBOARD.GET_ACTIVE_WARRANTS, setter: (value) => { - activeWarrants = - (value as typeof activeWarrants) || activeWarrants; + activeWarrants = Array.isArray(value) ? value : activeWarrants; }, errorMsg: "Failed to fetch active warrants", }, From 3cdd1bfd61154eed6da51cf9ea087a91d4d562ae Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:33:41 +0200 Subject: [PATCH 126/144] fixed activewarrants function error fixed activewarrants function error --- web/src/pages/Dashboard.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Dashboard.svelte b/web/src/pages/Dashboard.svelte index 1276093c..1dc82579 100644 --- a/web/src/pages/Dashboard.svelte +++ b/web/src/pages/Dashboard.svelte @@ -72,9 +72,9 @@ let warrantPage = $state(0); let boloPage = $state(0); - let warrantTotalPages = $derived(Math.max(1, Math.ceil(dashboardService.activeWarrants.length / PAGE_SIZE))); + let warrantTotalPages = $derived(Math.max(1, Math.ceil((dashboardService.activeWarrants ?? []).length / PAGE_SIZE))); let boloTotalPages = $derived(Math.max(1, Math.ceil((dashboardService.activeBolos || []).length / PAGE_SIZE))); - let pagedWarrants = $derived(dashboardService.activeWarrants.slice(warrantPage * PAGE_SIZE, (warrantPage + 1) * PAGE_SIZE)); + let pagedWarrants = $derived((dashboardService.activeWarrants ?? []).slice(warrantPage * PAGE_SIZE, (warrantPage + 1) * PAGE_SIZE)); let pagedBolos = $derived((dashboardService.activeBolos || []).slice(boloPage * PAGE_SIZE, (boloPage + 1) * PAGE_SIZE)); // ── Callsign ── From 79c978f3a4b35303006540a2b36b440abd6daf9b Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:41:39 +0200 Subject: [PATCH 127/144] Update README.md --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 223d0cf4..9639f94e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS mdt_patrols ( id VARCHAR(64) PRIMARY KEY, name VARCHAR(64) NOT NULL, color VARCHAR(7) NOT NULL, + zone_points LONGTEXT NULL DEFAULT NULL, sort_order INT NOT NULL DEFAULT 0, member_ids TEXT NOT NULL DEFAULT '[]' ); @@ -35,9 +36,6 @@ CREATE TABLE IF NOT EXISTS `mdt_bulletin_posts` ( KEY `idx_deleted_at` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -ALTER TABLE `mdt_bulletin_posts` - MODIFY COLUMN `category` VARCHAR(48) NOT NULL DEFAULT 'general'; - CREATE TABLE IF NOT EXISTS `mdt_bulletin_categories` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `job` VARCHAR(50) NOT NULL, @@ -50,7 +48,7 @@ CREATE TABLE IF NOT EXISTS `mdt_bulletin_categories` ( PRIMARY KEY (`id`), UNIQUE KEY `uq_job_value` (`job`, `value`), INDEX `idx_job_order` (`job`, `sort_order`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=UTF8MB4_UNICODE_CI; ``` # ps-mdt v3 From 5829547f688f691ed2272fb4b99106cafdb5ec22 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:43:10 +0200 Subject: [PATCH 128/144] changed to new animation ( looks better ) --- client/animation.lua | 10 +++++----- client/keys.lua | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/animation.lua b/client/animation.lua index e68dd589..0ac64766 100644 --- a/client/animation.lua +++ b/client/animation.lua @@ -1,7 +1,7 @@ local tabletProp = nil local isPlayingTabletAnim = false -local animDict = Config.Animation and Config.Animation.Dict or 'amb@world_human_tourist_map@male@base' -local animName = Config.Animation and Config.Animation.Name or 'base' +local animDict = Config.Animation and Config.Animation.Dict or 'amb@code_human_in_bus_passenger_idles@female@tablet@idle_a' +local animName = Config.Animation and Config.Animation.Name or 'idle_a' local propModel = 'ps-mdt' -- Helper to always get current ped (never stale) @@ -12,10 +12,10 @@ end -- Options ---------------------------------- local propOptions = { - xPos = 0.0, -- X-axis offset from the center of entity2 - yPos = -0.03, -- Y-axis offset from the center of entity2 + xPos = -0.050, -- X-axis offset from the center of entity2 + yPos = 0.0, -- Y-axis offset from the center of entity2 zPos = 0.0, -- Z-axis offset from the center of entity2 - xRot = 20.0, -- X-axis rotation + xRot = 0.0, -- X-axis rotation yRot = -90.0, -- Y-axis rotation zRot = 0.0, -- Z-axis rotation p9 = true, -- Unknown diff --git a/client/keys.lua b/client/keys.lua index 0e0023f8..3f2f44f9 100644 --- a/client/keys.lua +++ b/client/keys.lua @@ -147,6 +147,7 @@ function OpenMDT() MDTOpen = true SendNUI('setVisible', { visible = true, debugMode = Config.Debug }) + SendMapCitizenId() SendMapUiState() if isCivilian then From 67f4b2b987e7e9cfb5298478d7450d4a253b3357 Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:43:28 +0200 Subject: [PATCH 129/144] new animation --- config.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config.lua b/config.lua index d8a43d3c..a3b10f62 100644 --- a/config.lua +++ b/config.lua @@ -17,7 +17,7 @@ Config.CivilianAccess = { Config.DateTime = { GameTime = true, -- If set to true, the game time will be used instead of the server time (boolean) TimeFormat = '24', -- Format for displaying time ('24' or '12') - DateFormat = "MM-DD-YYYY" -- Format for displaying date (string: "MM-DD-YYYY", "DD-MM-YYYY", or "YYYY-MM-DD") + DateFormat = "DD-MM-YYYY" -- Format for displaying date (string: "MM-DD-YYYY", "DD-MM-YYYY", or "YYYY-MM-DD") } -- Department data sharing @@ -100,7 +100,7 @@ Config.FingerprintAutoFilled = false -- Auto-populate fingerprints on citizen pr -- Fingerprint Scan Integration Config.FingerprintScan = { - enabled = false, -- Enable fingerprint scan trigger from MDT + enabled = true, -- Enable fingerprint scan trigger from MDT officerEvent = 'police:client:showFingerprint', -- Client event triggered on the officer suspectEvent = 'police:client:showFingerprint', -- Client event triggered on the suspect } @@ -182,8 +182,8 @@ Config.CacheTTL = { -- Tablet Animation Config.Animation = { - Dict = 'amb@world_human_tourist_map@male@base', - Name = 'base', + Dict = 'amb@code_human_in_bus_passenger_idles@female@tablet@idle_a', + Name = 'idle_a', } -- Mugshot Camera @@ -350,4 +350,4 @@ Config.Weapons = { { model = "weapon_doubleaction", label = "Double-Action Revolver" }, { model = "weapon_navyrevolver", label = "Navy Revolver" }, { model = "weapon_musket", label = "Musket" }, -} +} \ No newline at end of file From b72a1ca966996c00a0275444435d6fdd00879aca Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:44:23 +0200 Subject: [PATCH 130/144] new setting ( notify for entering/leaving the zone ) + new zone feature --- server/backend/settings.lua | 13 +- server/backend/tracking.lua | 538 ++++++++++++++++++++++++++++++------ 2 files changed, 460 insertions(+), 91 deletions(-) diff --git a/server/backend/settings.lua b/server/backend/settings.lua index d8b56a40..7378e074 100644 --- a/server/backend/settings.lua +++ b/server/backend/settings.lua @@ -44,6 +44,16 @@ local ACTION_CATEGORIES = { icu_deleted = 'icu', camera_viewed = 'cameras', bodycam_viewed = 'bodycams', + -- ── Patrol management ──────────────────────────────────────────────────── + patrol_created = 'patrols', + patrol_deleted = 'patrols', + patrol_renamed = 'patrols', + patrol_zone_created = 'patrols', + patrol_zone_updated = 'patrols', + patrol_zone_cleared = 'patrols', + patrol_officer_assigned = 'patrols', + patrol_officer_removed = 'patrols', + patrols_reordered = 'patrols', } -- Cache for tracking config (loaded once, updated on save) @@ -67,6 +77,7 @@ local function getDefaultTracking() icu = true, cameras = true, bodycams = true, + patrols = true, -- ← new } end @@ -410,4 +421,4 @@ ps.registerCallback(resourceName .. ':server:saveColorConfig', function(source, end return { success = true } -end) +end) \ No newline at end of file diff --git a/server/backend/tracking.lua b/server/backend/tracking.lua index f89106ee..2ced8155 100644 --- a/server/backend/tracking.lua +++ b/server/backend/tracking.lua @@ -1,11 +1,65 @@ --- server_patrols.lua +-- ============================================================================ +-- server_patrols.lua — MDT patrol & live tracking (server side) +-- ---------------------------------------------------------------------------- +-- Responsibilities: +-- * Live officer/vehicle tracking served to the map NUI (getTracking). +-- * Patrol CRUD + ordering + zone storage, persisted in `mdt_patrols`. +-- * Audit logging of every patrol mutation via ps.auditLog. +-- +-- Performance notes for future devs: +-- * getTracking is polled by EVERY open MDT (~every 4.5s in the NUI). The +-- result is cached for TRACKING_CACHE_TTL ms so the heavy player/entity +-- scan runs at most once per TTL no matter how many MDTs are open. Never +-- call getAllTrackers() directly from the callback — use getTrackingSnapshot(). +-- * vehicleCache holds recently-parked police vehicles. Entries self-expire +-- after VEHICLE_CACHE_TTL so ghost markers disappear even when the +-- 'entityRemoved' event never fires (common when entities get culled). +-- Cache-served vehicles carry `cached = true` so the NUI can dim them. +-- * DB writes for frequent mutations (assign/remove) are debounced +-- (SAVE_DEBOUNCE_MS) and coalesced per patrol. Structural changes +-- (create/rename/zone/delete) are written immediately so a crash can't +-- lose them. +-- * broadcastPatrols coalesces plain broadcasts to one per frame. Action +-- broadcasts (assigned/removed) bypass coalescing because the NUI needs +-- the flash hint immediately. +-- +-- Set MDT_DEBUG = true for verbose console logging while developing. +-- ============================================================================ + local resourceName = tostring(GetCurrentResourceName()) -local vehicleCache = {} -local cacheVehicleCooldowns = {} -local patrols = {} -- { [id] = patrol } -local patrolOrder = {} -- { id, id, id, ... } – ordered list --- ─── Validierung ────────────────────────────────────────────────────────── +-- ─── Tunables ─────────────────────────────────────────────────────────────── +local MDT_DEBUG = false -- verbose dev logging; KEEP FALSE on production (log spam) +local TRACKING_CACHE_TTL = 2000 -- ms — shared tracking snapshot lifetime +local VEHICLE_CACHE_TTL = 600000 -- ms — parked-vehicle cache entry lifetime (10 min) +local SAVE_DEBOUNCE_MS = 1000 -- ms — coalesce window for debounced patrol saves + +-- ─── State ────────────────────────────────────────────────────────────────── +local vehicleCache = {} -- [plate] = { plate, coords, heading, _ts } +local cacheVehicleCooldowns = {} -- [src] = os.time() of last cacheVehicle event +local patrols = {} -- [id] = patrol +local patrolOrder = {} -- ordered list of patrol ids +local trackingCache = { vehicles = {}, bodycams = {}, ts = 0 } + +-- ─── Tiny helpers ───────────────────────────────────────────────────────── + +-- Gated dev logger. No-op unless MDT_DEBUG is on, so it's free in production. +local function dbg(...) + if MDT_DEBUG then print('[MDT]', ...) end +end + +-- Cache the QBCore object once instead of crossing the export boundary on +-- every call. Returns nil on non-QB frameworks (then the `ps`/ESX path is used). +local _qbCore +local function getQBCore() + if _qbCore then return _qbCore end + if exports['qb-core'] then + _qbCore = exports['qb-core']:GetCoreObject() + end + return _qbCore +end + +-- ─── Validation ───────────────────────────────────────────────────────────── local function isValidPatrolId(id) return type(id) == "string" and #id > 0 and #id <= 64 @@ -20,17 +74,121 @@ local function isValidCitizenId(cid) return type(cid) == "string" and #cid > 0 and #cid <= 64 end --- ─── DB ─────────────────────────────────────────────────────────────────── +-- Validate zone_points: array of {x, y} pairs, max 64 points +local function isValidZonePoints(points) + if points == nil then return true end -- nil = no zone, valid + if type(points) ~= "table" then return false end + if #points > 64 then return false end + for _, pt in ipairs(points) do + if type(pt) ~= "table" then return false end + if type(pt.x) ~= "number" or type(pt.y) ~= "number" then return false end + if pt.x < -10000 or pt.x > 10000 or pt.y < -10000 or pt.y > 10000 then return false end + end + return true +end -local function savePatrol(patrol) +-- ─── Audit helpers ──────────────────────────────────────────────────────── + +local function getOfficerInfo(src) + local QBCore = getQBCore() + if QBCore then + local player = QBCore.Functions.GetPlayer(src) + if player then + local d = player.PlayerData + return { + citizenid = d.citizenid, + name = d.charinfo.firstname .. ' ' .. d.charinfo.lastname, + callsign = d.metadata and d.metadata.callsign or nil, + rank = d.job and d.job.grade and d.job.grade.name or nil, + job = d.job and d.job.name or nil, + } + end + elseif ps and ps.getIdentifier then + local name = (ps.getPlayerName and ps.getPlayerName(src)) or GetPlayerName(src) or 'Unknown' + return { + citizenid = ps.getIdentifier(src), + name = name, + callsign = ps.getMetadata and ps.getMetadata(src, 'callsign') or nil, + rank = ps.getJobGradeName and ps.getJobGradeName(src) or nil, + job = ps.getJobName and ps.getJobName(src) or nil, + } + end + return { name = GetPlayerName(src) or ('Player #' .. src) } +end + +-- Resolve a citizenid -> "Firstname Lastname" without scanning every online +-- player. GetPlayerByCitizenId is a direct lookup on QBCore. +local function getNameByCitizenId(citizenId) + local QBCore = getQBCore() + if QBCore and QBCore.Functions.GetPlayerByCitizenId then + local p = QBCore.Functions.GetPlayerByCitizenId(citizenId) + if p and p.PlayerData and p.PlayerData.charinfo then + return p.PlayerData.charinfo.firstname .. ' ' .. p.PlayerData.charinfo.lastname + end + end + return citizenId +end + +local function auditPatrol(src, action, patrolId, extra) + if not ps.auditLog then return end + local officer = getOfficerInfo(src) + local data = { + officer_name = officer.name, + officer_callsign = officer.callsign, + officer_rank = officer.rank, + officer_id = officer.citizenid, + } + if extra then + for k, v in pairs(extra) do data[k] = v end + end + ps.auditLog(src, action, 'mdt_patrol', patrolId or 'none', data) +end + +-- ─── DB ───────────────────────────────────────────────────────────────────── + +-- Immediate write. Used directly for structural changes; the debounced +-- savePatrol() below funnels frequent membership writes through this. +local function savePatrolNow(patrol) if not patrol then return end MySQL.insert( - "INSERT INTO mdt_patrols (id, name, color, member_ids, sort_order) VALUES (?, ?, ?, ?, ?) " .. - "ON DUPLICATE KEY UPDATE name = VALUES(name), color = VALUES(color), member_ids = VALUES(member_ids), sort_order = VALUES(sort_order)", - { patrol.id, patrol.name, patrol.color, json.encode(patrol.memberIds), patrol.sortOrder or 0 } + "INSERT INTO mdt_patrols (id, name, color, member_ids, sort_order, zone_points) VALUES (?, ?, ?, ?, ?, ?) " .. + "ON DUPLICATE KEY UPDATE name = VALUES(name), color = VALUES(color), " .. + "member_ids = VALUES(member_ids), sort_order = VALUES(sort_order), zone_points = VALUES(zone_points)", + { + patrol.id, + patrol.name, + patrol.color, + json.encode(patrol.memberIds), + patrol.sortOrder or 0, + patrol.zonePoints and json.encode(patrol.zonePoints) or nil, + } ) end +-- Debounced save: coalesces rapid writes for the same patrol into a single DB +-- write after SAVE_DEBOUNCE_MS. Heavy assign/remove churn no longer hits the DB +-- on every click. Always references the live patrol table, so the flush writes +-- the latest state. delete cancels any pending save (see deletePatrol). +local pendingSaves = {} -- [id] = patrol +local saveTimerArmed = false + +local function flushSaves() + saveTimerArmed = false + for id, patrol in pairs(pendingSaves) do + pendingSaves[id] = nil + savePatrolNow(patrol) + end +end + +local function savePatrol(patrol) + if not patrol then return end + pendingSaves[patrol.id] = patrol + if not saveTimerArmed then + saveTimerArmed = true + SetTimeout(SAVE_DEBOUNCE_MS, flushSaves) + end +end + local function deletePatrolFromDB(id) MySQL.execute("DELETE FROM mdt_patrols WHERE id = ?", { id }) end @@ -44,11 +202,9 @@ local function saveOrder() end end --- ─── Broadcast ──────────────────────────────────────────────────────────── +-- ─── Broadcast ────────────────────────────────────────────────────────────── --- Sends patrols as sorted array instead of map, --- so all clients see the same order -local function broadcastPatrols(action, citizenid) +local function doBroadcast(action, citizenid) local ordered = {} for _, id in ipairs(patrolOrder) do if patrols[id] then @@ -58,15 +214,36 @@ local function broadcastPatrols(action, citizenid) TriggerClientEvent(resourceName .. ":client:syncPatrols", -1, ordered, action, citizenid) end +-- Plain broadcasts (create/delete/rename/reorder/zone/disconnect) are coalesced +-- to one per frame so a burst of mutations doesn't fan the full list out to +-- every client multiple times. Action broadcasts (assigned/removed) carry a +-- flash hint the NUI needs, so they go out immediately. +local broadcastScheduled = false +local function broadcastPatrols(action, citizenid) + if action then + doBroadcast(action, citizenid) + return + end + if broadcastScheduled then return end + broadcastScheduled = true + SetTimeout(0, function() + broadcastScheduled = false + doBroadcast() + end) +end + -- ─── Tracking ───────────────────────────────────────────────────────────── +-- HEAVY: scans all on-duty police players + their vehicles. Do not call this +-- per client request — it's wrapped by getTrackingSnapshot() which caches it. local function getAllTrackers() local vehicles = {} local bodycams = {} local seenVehicles = {} + local now = GetGameTimer() - if exports['qb-core'] then - local QBCore = exports['qb-core']:GetCoreObject() + local QBCore = getQBCore() + if QBCore then local players = QBCore.Functions.GetQBPlayers() or {} for _, player in pairs(players) do @@ -79,27 +256,25 @@ local function getAllTrackers() if not ped or ped == 0 then goto continue end local coords = GetEntityCoords(ped) + local veh = GetVehiclePedIsIn(ped, false) bodycams[#bodycams + 1] = { citizenid = data.citizenid, - name = data.charinfo.firstname .. ' ' .. data.charinfo.lastname, - callsign = data.metadata and data.metadata.callsign or nil, - rank = data.job.grade and data.job.grade.name or 'Officer', - coords = { x = coords.x, y = coords.y, z = coords.z }, - heading = GetEntityHeading(ped), + name = data.charinfo.firstname .. ' ' .. data.charinfo.lastname, + callsign = data.metadata and data.metadata.callsign or nil, + rank = data.job.grade and data.job.grade.name or 'Officer', + coords = { x = coords.x, y = coords.y, z = coords.z }, + heading = GetEntityHeading(ped), + inVehicle = veh and veh ~= 0, } - local veh = GetVehiclePedIsIn(ped, false) if veh and veh ~= 0 and not seenVehicles[veh] then seenVehicles[veh] = true - local vCoords = GetEntityCoords(veh) - local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '') - local vEntry = { - plate = plate, - coords = { x = vCoords.x, y = vCoords.y, z = vCoords.z }, - heading = GetEntityHeading(veh), - } - vehicles[#vehicles + 1] = vEntry - vehicleCache[plate] = vEntry + local vCoords = GetEntityCoords(veh) + local vHeading = GetEntityHeading(veh) + local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '') + local coordsTbl = { x = vCoords.x, y = vCoords.y, z = vCoords.z } + vehicles[#vehicles + 1] = { plate = plate, coords = coordsTbl, heading = vHeading } + vehicleCache[plate] = { plate = plate, coords = coordsTbl, heading = vHeading, _ts = now } end ::continue:: end @@ -116,79 +291,107 @@ local function getAllTrackers() if not ped or ped == 0 then goto continue end local coords = GetEntityCoords(ped) + local veh = GetVehiclePedIsIn(ped, false) bodycams[#bodycams + 1] = { citizenid = ps.getIdentifier and ps.getIdentifier(playerId) or nil, - name = (ps.getPlayerName and ps.getPlayerName(playerId)) or GetPlayerName(playerId) or 'Unknown', - callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil, - rank = ps.getJobGradeName and ps.getJobGradeName(playerId) or 'Officer', - coords = { x = coords.x, y = coords.y, z = coords.z }, - heading = GetEntityHeading(ped), + name = (ps.getPlayerName and ps.getPlayerName(playerId)) or GetPlayerName(playerId) or 'Unknown', + callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil, + rank = ps.getJobGradeName and ps.getJobGradeName(playerId) or 'Officer', + coords = { x = coords.x, y = coords.y, z = coords.z }, + heading = GetEntityHeading(ped), + inVehicle = veh and veh ~= 0, } - local veh = GetVehiclePedIsIn(ped, false) if veh and veh ~= 0 and not seenVehicles[veh] then seenVehicles[veh] = true - local vCoords = GetEntityCoords(veh) - local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '') - local vEntry = { - plate = plate, - coords = { x = vCoords.x, y = vCoords.y, z = vCoords.z }, - heading = GetEntityHeading(veh), - } - vehicles[#vehicles + 1] = vEntry - vehicleCache[plate] = vEntry + local vCoords = GetEntityCoords(veh) + local vHeading = GetEntityHeading(veh) + local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '') + local coordsTbl = { x = vCoords.x, y = vCoords.y, z = vCoords.z } + vehicles[#vehicles + 1] = { plate = plate, coords = coordsTbl, heading = vHeading } + vehicleCache[plate] = { plate = plate, coords = coordsTbl, heading = vHeading, _ts = now } end ::continue:: end end - -- Cached vehicles not currently driven by anyone + -- Merge in cached (parked / recently-left) police vehicles that weren't + -- seen live this pass. Stale entries are pruned here so ghost markers vanish + -- after VEHICLE_CACHE_TTL even if 'entityRemoved' never fires. These carry + -- `cached = true` so the NUI can render them dimmed ("last known position"). + local seenPlates = {} + for _, v in ipairs(vehicles) do seenPlates[v.plate] = true end for plate, cacheData in pairs(vehicleCache) do - if not seenVehicles[cacheData._entity] then - local alreadyAdded = false - for _, v in pairs(vehicles) do - if v.plate == plate then alreadyAdded = true; break end - end - if not alreadyAdded then - vehicles[#vehicles + 1] = cacheData - end + if (now - (cacheData._ts or 0)) > VEHICLE_CACHE_TTL then + vehicleCache[plate] = nil + elseif not seenPlates[plate] then + vehicles[#vehicles + 1] = { + plate = cacheData.plate, + coords = cacheData.coords, + heading = cacheData.heading, + cached = true, + } + seenPlates[plate] = true end end return vehicles, bodycams end +-- Returns a shared, throttled snapshot. With N MDTs open the expensive scan +-- runs at most once per TRACKING_CACHE_TTL instead of N times per poll cycle. +local function getTrackingSnapshot() + local now = GetGameTimer() + if trackingCache.ts ~= 0 and (now - trackingCache.ts) < TRACKING_CACHE_TTL then + return trackingCache + end + local vehicles, bodycams = getAllTrackers() + trackingCache.vehicles = vehicles + trackingCache.bodycams = bodycams + trackingCache.ts = now + return trackingCache +end + ps.registerCallback(resourceName .. ':server:getTracking', function(source) if not CheckAuth(source) then return { vehicles = {}, bodycams = {} } end - local vehicles, bodycams = getAllTrackers() - return { vehicles = vehicles, bodycams = bodycams } + local snap = getTrackingSnapshot() + return { vehicles = snap.vehicles, bodycams = snap.bodycams } end) RegisterNetEvent(resourceName .. ':server:cacheVehicle', function(plate, coords, heading) local src = source + -- Defense in depth: only authorised, on-duty police may inject cache markers. + if not CheckAuth(src) then return end if type(plate) ~= 'string' or #plate == 0 or #plate > 8 then return end if type(coords) ~= 'table' or type(coords.x) ~= 'number' or type(coords.y) ~= 'number' or type(coords.z) ~= 'number' then return end if type(heading) ~= 'number' or heading < 0 or heading > 360 then return end if coords.x < -4000 or coords.x > 4000 or coords.y < -4000 or coords.y > 8000 then return end - local now = os.time() - if cacheVehicleCooldowns[src] and now - cacheVehicleCooldowns[src] < 5 then return end - cacheVehicleCooldowns[src] = now + local nowSec = os.time() + if cacheVehicleCooldowns[src] and nowSec - cacheVehicleCooldowns[src] < 5 then return end + cacheVehicleCooldowns[src] = nowSec - if exports['qb-core'] then - local QBCore = exports['qb-core']:GetCoreObject() + -- Per-framework duty/police check (QB path and ps/ESX path). + local QBCore = getQBCore() + if QBCore then local player = QBCore.Functions.GetPlayer(src) if not player then return end local job = player.PlayerData.job if not job or not job.onduty or not IsPoliceJob(job.name, job.type) then return end + elseif ps and ps.getJobName then + if not (ps.getJobDuty and ps.getJobDuty(src)) then return end + local jobName = ps.getJobName(src) + local jobType = ps.getJobType and ps.getJobType(src) or nil + if not IsPoliceJob(jobName, jobType) then return end end vehicleCache[plate] = { plate = plate, coords = { x = coords.x, y = coords.y, z = coords.z }, heading = heading, + _ts = GetGameTimer(), } end) @@ -216,8 +419,8 @@ end) -- ─── Patrols ────────────────────────────────────────────────────────────── ps.registerCallback(resourceName .. ":server:getPatrols", function(source) - if not CheckAuth(source) then return {} end - -- Return sorted array + local src = source + if not CheckAuth(src) then return {} end local ordered = {} for _, id in ipairs(patrolOrder) do if patrols[id] then @@ -234,19 +437,30 @@ RegisterNetEvent(resourceName .. ":server:createPatrol", function(id, name, colo if patrols[id] then return end local sortOrder = #patrolOrder + 1 - patrols[id] = { id = id, name = name, color = color, memberIds = {}, sortOrder = sortOrder } + patrols[id] = { id = id, name = name, color = color, memberIds = {}, zonePoints = nil, sortOrder = sortOrder } patrolOrder[#patrolOrder + 1] = id broadcastPatrols() - savePatrol(patrols[id]) + savePatrolNow(patrols[id]) -- structural change → write immediately + dbg(('patrol created: "%s" (%s)'):format(name, id)) + auditPatrol(src, 'patrol_created', id, { + patrol_name = name, + patrol_color = color, + action_label = ('Created patrol "%s"'):format(name), + }) end) RegisterNetEvent(resourceName .. ":server:deletePatrol", function(id) local src = source if not CheckAuth(src) then return end if not isValidPatrolId(id) then return end + if not patrols[id] then return end + -- Capture the name BEFORE removing the entry (previous code read it after + -- nil-ing patrols[id], so the audit log always showed the raw id). + local deletedName = patrols[id].name or id + + pendingSaves[id] = nil -- cancel any debounced save so it can't re-insert the row patrols[id] = nil - -- Remove from order list for i = #patrolOrder, 1, -1 do if patrolOrder[i] == id then table.remove(patrolOrder, i) @@ -256,6 +470,11 @@ RegisterNetEvent(resourceName .. ":server:deletePatrol", function(id) deletePatrolFromDB(id) saveOrder() broadcastPatrols() + dbg(('patrol deleted: "%s" (%s)'):format(deletedName, id)) + auditPatrol(src, 'patrol_deleted', id, { + patrol_name = deletedName, + action_label = ('Deleted patrol "%s"'):format(deletedName), + }) end) RegisterNetEvent(resourceName .. ":server:renamePatrol", function(id, newName) @@ -264,18 +483,55 @@ RegisterNetEvent(resourceName .. ":server:renamePatrol", function(id, newName) if not isValidPatrolId(id) or not isValidName(newName) then return end if not patrols[id] then return end + local oldName = patrols[id].name patrols[id].name = newName broadcastPatrols() - savePatrol(patrols[id]) + savePatrolNow(patrols[id]) -- structural change → write immediately + dbg(('patrol renamed: "%s" -> "%s"'):format(oldName, newName)) + auditPatrol(src, 'patrol_renamed', id, { + patrol_old_name = oldName, + patrol_new_name = newName, + action_label = ('Renamed patrol "%s" → "%s"'):format(oldName, newName), + }) +end) + +-- ─── Zone Points ──────────────────────────────────────────────────────────── +-- Client sends updated zone_points for a patrol after drawing on the map. +-- points = array of { x, y } in GTA world coordinates, or nil to clear zone. +RegisterNetEvent(resourceName .. ":server:setPatrolZone", function(id, points) + local src = source + if not CheckAuth(src) then return end + if not isValidPatrolId(id) then return end + if not patrols[id] then return end + if not isValidZonePoints(points) then return end + + -- nil clears the zone; fewer than 3 points also clears it + local hadZone = patrols[id].zonePoints ~= nil + patrols[id].zonePoints = (points and #points >= 3) and points or nil + broadcastPatrols() + savePatrolNow(patrols[id]) -- zone data is precious → write immediately + local zoneAction = patrols[id].zonePoints + and (hadZone and 'patrol_zone_updated' or 'patrol_zone_created') + or 'patrol_zone_cleared' + local pts = patrols[id].zonePoints and #patrols[id].zonePoints or 0 + local label = patrols[id].zonePoints + and ('Drew zone for patrol "%s" (%d points)'):format(patrols[id].name, pts) + or ('Cleared zone for patrol "%s"'):format(patrols[id].name) + dbg(label) + auditPatrol(src, zoneAction, id, { + patrol_name = patrols[id].name, + point_count = pts, + action_label = label, + }) end) --- New order from client – ids is an array of patrol IDs in the desired order +-- ─── Order / Assign ─────────────────────────────────────────────────────── + RegisterNetEvent(resourceName .. ":server:reorderPatrols", function(ids) local src = source if not CheckAuth(src) then return end if type(ids) ~= "table" then return end - -- Validate: only known IDs, no duplicates local seen = {} local newOrder = {} for _, id in ipairs(ids) do @@ -284,7 +540,6 @@ RegisterNetEvent(resourceName .. ":server:reorderPatrols", function(ids) newOrder[#newOrder + 1] = id end end - -- Append any missing IDs for _, id in ipairs(patrolOrder) do if not seen[id] then newOrder[#newOrder + 1] = id @@ -294,6 +549,17 @@ RegisterNetEvent(resourceName .. ":server:reorderPatrols", function(ids) patrolOrder = newOrder saveOrder() broadcastPatrols() + local nameOrder = {} + for _, pid in ipairs(newOrder) do + nameOrder[#nameOrder + 1] = patrols[pid] and patrols[pid].name or pid + end + dbg('patrols reordered: ' .. table.concat(nameOrder, ' -> ')) + -- NOTE: previous code passed `extra` as a 5th arg (after a nil), so it was + -- silently dropped. auditPatrol is (src, action, patrolId, extra). + auditPatrol(src, 'patrols_reordered', 'order', { + new_order = table.concat(nameOrder, ' → '), + action_label = 'Reordered patrols: ' .. table.concat(nameOrder, ' → '), + }) end) RegisterNetEvent(resourceName .. ":server:assignOfficer", function(patrolId, citizenId) @@ -311,7 +577,16 @@ RegisterNetEvent(resourceName .. ":server:assignOfficer", function(patrolId, cit end table.insert(patrols[patrolId].memberIds, citizenId) broadcastPatrols("assigned", citizenId) - savePatrol(patrols[patrolId]) + savePatrol(patrols[patrolId]) -- frequent mutation → debounced + + local assignedName = getNameByCitizenId(citizenId) + dbg(('assigned %s to "%s"'):format(assignedName, patrols[patrolId].name)) + auditPatrol(src, 'patrol_officer_assigned', patrolId, { + patrol_name = patrols[patrolId].name, + assigned_name = assignedName, + assigned_id = citizenId, + action_label = ('Assigned %s to patrol "%s"'):format(assignedName, patrols[patrolId].name), + }) end) RegisterNetEvent(resourceName .. ":server:removeFromPatrol", function(citizenId) @@ -319,53 +594,136 @@ RegisterNetEvent(resourceName .. ":server:removeFromPatrol", function(citizenId) if not CheckAuth(src) then return end if not isValidCitizenId(citizenId) then return end + -- Find which patrol the officer belongs to BEFORE removal (for the audit log) + local removedFromPatrol = 'unknown' + for _, patrol in pairs(patrols) do + for _, mid in ipairs(patrol.memberIds) do + if mid == citizenId then removedFromPatrol = patrol.name; break end + end + end for _, patrol in pairs(patrols) do for i = #patrol.memberIds, 1, -1 do if patrol.memberIds[i] == citizenId then table.remove(patrol.memberIds, i) - savePatrol(patrol) + savePatrol(patrol) -- frequent mutation → debounced end end end broadcastPatrols("removed", citizenId) + -- Only audit if the officer was actually found in a patrol + if removedFromPatrol ~= 'unknown' then + dbg(('removed %s from "%s"'):format(citizenId, removedFromPatrol)) + auditPatrol(src, 'patrol_officer_removed', citizenId, { + removed_id = citizenId, + removed_from = removedFromPatrol, + action_label = ('Removed officer from patrol "%s"'):format(removedFromPatrol), + }) + end end) AddEventHandler("playerDropped", function() - cacheVehicleCooldowns[source] = nil + local src = source + cacheVehicleCooldowns[src] = nil - if exports["qb-core"] then - local QBCore = exports["qb-core"]:GetCoreObject() - local player = QBCore.Functions.GetPlayer(source) + local citizenId = nil + local officerName = GetPlayerName(src) or ('Player #' .. src) + + local QBCore = getQBCore() + if QBCore then + local player = QBCore.Functions.GetPlayer(src) if player then - local citizenId = player.PlayerData.citizenid - for _, patrol in pairs(patrols) do - for i = #patrol.memberIds, 1, -1 do - if patrol.memberIds[i] == citizenId then - table.remove(patrol.memberIds, i) - savePatrol(patrol) - end - end + citizenId = player.PlayerData.citizenid + officerName = player.PlayerData.charinfo.firstname .. ' ' .. player.PlayerData.charinfo.lastname + end + elseif ps and ps.getIdentifier then + citizenId = ps.getIdentifier(src) + officerName = (ps.getPlayerName and ps.getPlayerName(src)) or officerName + end + + if not citizenId then return end + + -- Find patrol membership BEFORE removal so we can log it + local removedFromPatrol = nil + local removedFromId = nil + for pid, patrol in pairs(patrols) do + for _, mid in ipairs(patrol.memberIds) do + if mid == citizenId then + removedFromPatrol = patrol.name + removedFromId = pid + break + end + end + if removedFromPatrol then break end + end + + -- Remove from patrol + for _, patrol in pairs(patrols) do + for i = #patrol.memberIds, 1, -1 do + if patrol.memberIds[i] == citizenId then + table.remove(patrol.memberIds, i) + savePatrol(patrol) -- frequent mutation → debounced end - broadcastPatrols() end end + + broadcastPatrols() + + -- Audit log only if they were actually in a patrol + if removedFromPatrol and ps.auditLog then + dbg(('%s disconnected from patrol "%s"'):format(officerName, removedFromPatrol)) + ps.auditLog(src, 'patrol_officer_removed', removedFromId or citizenId, { + officer_name = officerName, + officer_id = citizenId, + removed_from = removedFromPatrol, + action_label = ('%s left patrol "%s" (disconnected)'):format(officerName, removedFromPatrol), + }) + end end) AddEventHandler("onResourceStart", function(res) if res ~= resourceName then return end - local rows = MySQL.query.await("SELECT * FROM mdt_patrols ORDER BY sort_order ASC") + + -- Wrapped in pcall so a missing/broken `mdt_patrols` table produces a clear + -- error for the next dev instead of a hard crash on boot. + local ok, rows = pcall(function() + return MySQL.query.await("SELECT * FROM mdt_patrols ORDER BY sort_order ASC") + end) + if not ok or type(rows) ~= "table" then + print(('^1[MDT]^7 Failed to load patrols. Is the `mdt_patrols` table installed? Error: %s') + :format(tostring(rows))) + return + end + patrolOrder = {} for _, row in ipairs(rows) do + local zonePoints = nil + if row.zone_points and row.zone_points ~= "" and row.zone_points ~= "null" then + local okDecode, decoded = pcall(json.decode, row.zone_points) + if okDecode and type(decoded) == "table" then + zonePoints = decoded + else + print(('^3[MDT]^7 Patrol "%s" has unparseable zone_points; ignoring.'):format(row.id)) + end + end patrols[row.id] = { id = row.id, name = row.name, color = row.color, memberIds = {}, + zonePoints = zonePoints, sortOrder = row.sort_order or 0, } patrolOrder[#patrolOrder + 1] = row.id end - -- Clear members in DB as well + -- Members reset on restart (officers need to be reassigned after a restart) MySQL.execute("UPDATE mdt_patrols SET member_ids = '[]'", {}) - print("[MDT] " .. #rows .. " patrols loaded.") + ps.debug(('^2[MDT]^7 Loaded %d patrol(s).') + :format(#rows)) +end) + +-- Flush any pending debounced saves before the resource stops so nothing queued +-- in the last SAVE_DEBOUNCE_MS window is lost on restart. +AddEventHandler("onResourceStop", function(res) + if res ~= resourceName then return end + flushSaves() end) \ No newline at end of file From bd6431d25e282e5975ae1a06089704fc5ed3db2f Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:44:42 +0200 Subject: [PATCH 131/144] new zone feature --- client/backend/tracking.lua | 277 +++++++++++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 5 deletions(-) diff --git a/client/backend/tracking.lua b/client/backend/tracking.lua index fc9d2a90..06407428 100644 --- a/client/backend/tracking.lua +++ b/client/backend/tracking.lua @@ -1,6 +1,32 @@ --- client_patrols.lua +-- ---------------------------------------------------------------------------- +-- Responsibilities: +-- * Forwards patrol/tracking data between server and the map NUI. +-- * Maintains one ox_lib PolyZone per patrol and fires enter/exit +-- notifications, but ONLY for the patrol the local player belongs to. +-- * Zones live independently of the MDT: they are built on spawn and on +-- resource start, so notifications work without ever opening the MDT. +-- +-- Performance notes for future devs: +-- * syncPatrols is broadcast to ALL clients on EVERY patrol mutation +-- (create/delete/assign/remove/rename/reorder/zone). syncZones() therefore +-- diffs each zone's geometry signature and only rebuilds zones whose points +-- actually changed — a rename/reorder/assign no longer tears down and +-- recreates every PolyZone on every client. +-- * Patrol names shown in zone notifications are read live from +-- zonePatrolNames, so a rename never forces a zone rebuild. +-- +-- Set MDT_DEBUG = true for verbose console logging while developing. +-- ============================================================================ + local resourceName = tostring(GetCurrentResourceName()) +-- ─── Tunables ─────────────────────────────────────────────────────────────── +local MDT_DEBUG = false -- verbose dev logging; KEEP FALSE on production + +local function dbg(...) + if MDT_DEBUG then print('[MDT]', ...) end +end + -- UI state held in Lua client (survives MDT open/close) local mapUiState = { sidebarOpen = true, @@ -8,22 +34,204 @@ local mapUiState = { patrolsOpen = true, } +-- ─── Patrol zone notification preference ───────────────────────────────────── +-- Read from localStorage via a NUI message on resource start. +-- Defaults to true (enabled) until the NUI reports otherwise. +local patrolZoneNotificationsEnabled = true + +AddEventHandler('onClientResourceStart', function(res) + if res ~= resourceName then return end + -- Ask the NUI to send us the stored preference + SendNUIMessage({ type = 'requestPatrolZonePref' }) +end) + +-- The NUI page reads localStorage and sends this back +RegisterNUICallback('patrolZonePref', function(data, cb) + if type(data.enabled) == 'boolean' then + patrolZoneNotificationsEnabled = data.enabled + end + cb({}) +end) + +-- ─── Notification helper (works with QBCore, ox_lib, ps-ui) ───────────────── + +local function mdtNotify(title, description, notifyType, duration) + -- Respect the player's preference stored in localStorage via NUI + if not patrolZoneNotificationsEnabled then return end + duration = duration or 4000 + if exports['ox_lib'] then + lib.notify({ title = title, description = description, type = notifyType, duration = duration }) + elseif exports['qb-core'] then + exports['qb-core']:GetCoreObject().Functions.Notify(('[%s] %s'):format(title, description), notifyType, duration) + elseif exports['ps-ui'] then + exports['ps-ui']:Notify({ text = ('[%s] %s'):format(title, description), type = notifyType, length = duration }) + else + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(('[%s] %s'):format(title, description)) + EndTextCommandThefeedPostTick() + end +end + +-- ─── Patrol Zone tracking ────────────────────────────────────────────────── +-- Keeps one PolyZone per patrol id. Zones are (re)built only when their +-- geometry changes (see syncZones). + +local activeZones = {} -- [patrolId] = ox_lib PolyZone object +local zoneSignatures = {} -- [patrolId] = geometry signature (string) +local zonePatrolNames = {} -- [patrolId] = latest patrol name (read live by callbacks) +local myPatrolId = nil -- patrol the local player currently belongs to + +local function getCitizenId() + if exports['qb-core'] then + return exports['qb-core']:GetCoreObject().Functions.GetPlayerData().citizenid + elseif ps and ps.getIdentifier then + return ps.getIdentifier() + end + return nil +end + +local function destroyZone(patrolId) + if activeZones[patrolId] then + activeZones[patrolId]:remove() + activeZones[patrolId] = nil + end +end + +local function destroyAllZones() + for id in pairs(activeZones) do + destroyZone(id) + end + zoneSignatures = {} + zonePatrolNames = {} +end + +-- A cheap, stable string fingerprint of a zone's points. Identical geometry => +-- identical signature => no rebuild needed. +local function pointsSignature(points) + if not points then return "" end + local parts = {} + for i = 1, #points do + parts[i] = string.format("%.1f,%.1f", points[i].x or 0.0, points[i].y or 0.0) + end + return table.concat(parts, ";") +end + +-- Convert GTA world {x,y} array → ox_lib poly-zone points (vector3; z required, +-- but ignored by lib.zones.poly). +local function toVector3List(points) + local vecs = {} + for i = 1, #points do + vecs[i] = vector3(points[i].x, points[i].y, 0.0) + end + return vecs +end + +local function createZoneForPatrol(patrol) + destroyZone(patrol.id) + + local points = patrol.zonePoints + if not points or #points < 3 then return end + + local vecs = toVector3List(points) + local pid = patrol.id + + -- Suppress the initial onEnter that fires when the zone is first created + -- (happens on spawn, MDT open, resource restart) + local suppressInitial = true + SetTimeout(2500, function() suppressInitial = false end) + + local zone = lib.zones.poly({ + points = vecs, + thickness = 1600, + debug = false, + onEnter = function() + if pid ~= myPatrolId then return end + if suppressInitial then return end + mdtNotify(zonePatrolNames[pid] or 'Patrol', 'entered Zone', 'success', 3000) + end, + onExit = function() + if pid ~= myPatrolId then return end + mdtNotify(zonePatrolNames[pid] or 'Patrol', 'left Zone', 'error', 5000) + end, + }) + + activeZones[pid] = zone +end + +-- Rebuild only the zones whose geometry changed, and figure out which patrol the +-- local player is in. Cheap to call on every syncPatrols broadcast. +local function syncZones(patrols) + local citizenId = getCitizenId() + myPatrolId = nil + + -- Destroy zones for patrols that no longer exist + local incoming = {} + for _, p in ipairs(patrols) do incoming[p.id] = true end + for id in pairs(activeZones) do + if not incoming[id] then + destroyZone(id) + zoneSignatures[id] = nil + zonePatrolNames[id] = nil + end + end + + for _, patrol in ipairs(patrols) do + -- Keep the name fresh so renames update notifications without a rebuild + zonePatrolNames[patrol.id] = patrol.name + + -- Resolve local membership + if citizenId then + for _, mid in ipairs(patrol.memberIds) do + if mid == citizenId then + myPatrolId = patrol.id + break + end + end + end + + local hasZone = patrol.zonePoints and #patrol.zonePoints >= 3 + if hasZone then + local sig = pointsSignature(patrol.zonePoints) + if zoneSignatures[patrol.id] ~= sig then + createZoneForPatrol(patrol) + zoneSignatures[patrol.id] = sig + dbg(('rebuilt zone for "%s" (%d pts)'):format(patrol.name, #patrol.zonePoints)) + end + else + if activeZones[patrol.id] then destroyZone(patrol.id) end + zoneSignatures[patrol.id] = nil + end + end +end + -- ─── Server → NUI ───────────────────────────────────────────────────────── RegisterNetEvent(resourceName .. ":client:syncPatrols", function(patrols, action, citizenid) SendNUIMessage({ type = "syncPatrols", data = patrols, action = action, citizenid = citizenid }) + + -- Rebuild PolyZones whenever patrol data changes (diffed inside syncZones) + if type(patrols) == "table" then + syncZones(patrols) + end end) +-- Called from client.lua after setVisible(true) — send citizenId so map centers on self +function SendMapCitizenId() + local cid = getCitizenId() + if cid then + SendNUIMessage({ type = 'setLocalCitizenId', data = { citizenid = cid } }) + end +end + RegisterNetEvent(resourceName .. ':client:checkVehicleClass', function(netId, plate, coords, heading) local veh = NetworkGetEntityFromNetworkId(netId) if not veh or veh == 0 then return end - if GetVehicleClass(veh) ~= 18 then return end + if GetVehicleClass(veh) ~= 18 then return end -- 18 = Emergency TriggerServerEvent(resourceName .. ':server:cacheVehicle', plate, coords, heading) end) -- ─── UI State ───────────────────────────────────────────────────────────── --- Call this in client.lua after SendNUI('setVisible', { visible = true }) function SendMapUiState() SendNUIMessage({ type = "mapUiState", data = mapUiState }) end @@ -37,7 +245,6 @@ end) -- ─── Tracking ───────────────────────────────────────────────────────────── --- Register only once – always call cb() to prevent timeout RegisterNUICallback("getTracking", function(_, cb) if not MDTOpen then cb({ success = false, data = { vehicles = {}, bodycams = {} } }) @@ -56,7 +263,12 @@ end) RegisterNUICallback("getPatrols", function(_, cb) local result = ps.callback(resourceName .. ":server:getPatrols") - cb({ success = true, data = result or {} }) + local patrols = result or {} + cb({ success = true, data = patrols }) + -- Also sync zones immediately on load + if type(patrols) == "table" then + syncZones(patrols) + end end) RegisterNUICallback("createPatrol", function(data, cb) @@ -106,4 +318,59 @@ RegisterNUICallback("removeFromPatrol", function(data, cb) if type(data.citizenId) ~= "string" then cb({ success = false }) return end TriggerServerEvent(resourceName .. ":server:removeFromPatrol", data.citizenId) cb({ success = true }) +end) + +-- ─── Cleanup ────────────────────────────────────────────────────────────── + +AddEventHandler("onResourceStop", function(res) + if res ~= resourceName then return end + destroyAllZones() +end) + +-- ─── Auto-init zones on spawn (independent of MDT) ──────────────────────── +-- Loads patrol zone data from the server as soon as the player spawns, so zone +-- entry/exit notifications work without ever opening the MDT. + +local function initZonesFromServer() + -- Send citizenId so the map can center on the officer's own position + local cid = getCitizenId() + if cid then + SendNUIMessage({ type = 'setLocalCitizenId', data = { citizenid = cid } }) + end + + local result = ps.callback(resourceName .. ":server:getPatrols") + if type(result) == "table" then + syncZones(result) + end +end + +-- Fires when the player first spawns +RegisterNetEvent('QBCore:Client:OnPlayerLoaded', function() + -- Small delay so the framework has finished setting up the player + SetTimeout(2000, initZonesFromServer) +end) + +-- ─── Zone Drawing ───────────────────────────────────────────────────────── +-- Called by the Svelte map after the user finishes drawing a zone polygon. +-- data.id = patrol id (string) +-- data.points = array of { x, y } in GTA world coordinates, or nil to clear +RegisterNUICallback("setPatrolZone", function(data, cb) + if not MDTOpen then cb({ success = false }) return end + if type(data.id) ~= "string" then cb({ success = false }) return end + + -- Validate points on client before sending to server + local points = data.points + if points ~= nil then + if type(points) ~= "table" or #points < 3 then + cb({ success = false, error = "need_3_points" }) return + end + for _, pt in ipairs(points) do + if type(pt) ~= "table" or type(pt.x) ~= "number" or type(pt.y) ~= "number" then + cb({ success = false, error = "invalid_point" }) return + end + end + end + + TriggerServerEvent(resourceName .. ":server:setPatrolZone", data.id, points) + cb({ success = true }) end) \ No newline at end of file From af8ca49f66d99a104a09351e8d315f0e5e23768a Mon Sep 17 00:00:00 2001 From: LeSiiN <103898231+LeSiiN@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:45:15 +0200 Subject: [PATCH 132/144] new zone feature --- web/src/pages/Map.svelte | 1513 ++++++++++++++++++--------------- web/src/pages/Settings.svelte | 174 ++-- 2 files changed, 912 insertions(+), 775 deletions(-) diff --git a/web/src/pages/Map.svelte b/web/src/pages/Map.svelte index 6da8c9fb..84b9c279 100644 --- a/web/src/pages/Map.svelte +++ b/web/src/pages/Map.svelte @@ -13,12 +13,10 @@ } let { authService }: Props = $props(); - // Default true = everyone can use it unless authService explicitly denies let canViewPatrols = $derived(authService ? (authService.hasPermission("map_patrols_view") ?? true) : true); let canManagePatrols = $derived(authService ? (authService.hasPermission("map_patrols_manage") ?? true) : true); let canEditPatrols = $derived(authService ? (authService.hasPermission("map_patrols_edit") ?? true) : true); - // ─── Map state ─────────────────────────────────────────────────────────── let mapContainer: HTMLDivElement | null = null; let map: L.Map | null = null; let mapInitialized = false; @@ -28,16 +26,16 @@ let showVehicles = $state(localStorage.getItem("mdt_map_vehicles") !== "false"); let showBodycams = $state(localStorage.getItem("mdt_map_bodycams") !== "false"); let showPatrols = $state(localStorage.getItem("mdt_map_patrols_layer") !== "false"); + let showZones = $state(localStorage.getItem("mdt_map_zones") !== "false"); let iconStyle = $state<"dot" | "badge">( (localStorage.getItem("mdt_map_icon_style") as "dot" | "badge") ?? "dot" ); let vehicleLayer = L.layerGroup(); let bodycamLayer = L.layerGroup(); - let patrolLayer = L.layerGroup(); + let patrolLayer = L.layerGroup(); + let zoneLayer = L.layerGroup(); - // ─── Sidebar state ──────────────────────────────────────────────────────── - // localStorage as default – overridden by Lua client on open let sidebarOpen = $state(localStorage.getItem("mdt_map_sidebar") !== "false"); let officersOpen = $state(localStorage.getItem("mdt_map_officers") !== "false"); let patrolsOpen = $state(localStorage.getItem("mdt_map_patrols") !== "false"); @@ -45,7 +43,6 @@ function toggleSidebar() { sidebarOpen = !sidebarOpen; localStorage.setItem("mdt_map_sidebar", String(sidebarOpen)); - // Also save in Lua client (survives resource restart) fetchNui(NUI_EVENTS.MAP.SAVE_UI_STATE, { key: "sidebarOpen", value: sidebarOpen }, {}).catch(() => {}); } function toggleOfficers() { @@ -59,12 +56,12 @@ fetchNui(NUI_EVENTS.MAP.SAVE_UI_STATE, { key: "patrolsOpen", value: patrolsOpen }, {}).catch(() => {}); } - // Sidebar-Breite: 260px pro offenem Panel + 36px pro zugeklapptem + 1px Divider let sidebarWidth = $derived( (officersOpen ? 260 : 36) + 1 + (patrolsOpen ? 260 : 36) ); - // ─── Patrol types ───────────────────────────────────────────────────────── + type GtaPoint = { x: number; y: number }; + type Bodycam = { citizenid: string; name: string; @@ -72,6 +69,7 @@ rank?: string; coords: { x: number; y: number; z: number }; heading?: number; + inVehicle?: boolean; }; type Patrol = { @@ -79,19 +77,147 @@ name: string; color: string; memberIds: string[]; + zonePoints?: GtaPoint[] | null; }; - // ─── Officers & Patrols ─────────────────────────────────────────────────── - let officers = $state([]); - let patrols = $state([]); + let officers = $state([]); + let patrols = $state([]); + let officerSearch = $state(""); + + // Search-filtered officer lists (recomputed when officers/patrols/search change) + let unassignedFiltered = $derived(filterOfficers(unassignedOfficers())); + let totalVisibleOfficers = $derived(filterOfficers(officers).length); + + // One-time centering flag — pan to own position on first data load + let centeredOnSelf = false; + // Own citizenId sent from Lua on open + let ownCitizenId: string | null = null; + + // Officer highlight state + let selectedOfficerId = $state(null); + let highlightMarker: L.Marker | null = null; + let highlightPopup: L.Popup | null = null; + + function selectOfficer(citizenid: string) { + // Toggle off if already selected + if (selectedOfficerId === citizenid) { + clearOfficerHighlight(); + return; + } + selectedOfficerId = citizenid; + highlightOfficerOnMap(citizenid); + } + + function clearOfficerHighlight() { + selectedOfficerId = null; + highlightMarker?.remove(); highlightMarker = null; + highlightPopup?.remove(); highlightPopup = null; + } + + // Build the full popup HTML for an officer + function buildOfficerPopupHtml(officer: Bodycam): string { + const patrol = getOfficerPatrol(officer.citizenid); + const color = patrol?.color ?? "#38bdf8"; + + // Heading → compass direction label + const headingLabel = (h: number) => { + const dirs = ["N","NE","E","SE","S","SW","W","NW","N"]; + return dirs[Math.round(((360 - h) % 360) / 45)]; + }; + const heading = officer.heading != null + ? ` + + + + ${headingLabel(officer.heading)} + ` + : ""; + + // Use server-provided flag — no coordinate guessing, no flicker + const inVehicle = officer.inVehicle ?? false; + const vehicleBadge = inVehicle + ? `🚔 In Vehicle` + : `🦶 On Foot`; + + const patrolHtml = patrol + ? `● ${patrol.name}` + : `● Unassigned`; + + return ` +
+
+
${officer.name}
+ ${officer.callsign ? `
${officer.callsign}
` : ""} +
+
+ ${officer.rank ? `
Rank${officer.rank}
` : ""} +
Patrol${patrolHtml}
+
Status${vehicleBadge}
+
+ Position + ${officer.coords.x.toFixed(0)}, ${officer.coords.y.toFixed(0)} + ${heading} +
+
+
+ `; + } + + function highlightOfficerOnMap(citizenid: string) { + if (!map) return; + const officer = officers.find(o => o.citizenid === citizenid); + if (!officer) return; + + const patrol = getOfficerPatrol(citizenid); + const color = patrol?.color ?? "#38bdf8"; + const latlng = toMapLatLng(officer.coords) as L.LatLng; + + if (highlightMarker) { + // Reposition existing marker + highlightMarker.setLatLng(latlng); + } else { + // First time: create marker + highlightMarker = L.marker(latlng, { + icon: L.divIcon({ + className: "", + html: `
`, + iconSize: [40, 40], + iconAnchor: [20, 20], + }), + zIndexOffset: 500, + interactive: false, + }).addTo(map); + + // Create popup + highlightPopup = L.popup({ + closeButton: true, + autoClose: false, + closeOnClick: false, + className: "officer-popup", + offset: [0, -8], + }) + .setLatLng(latlng) + .setContent(buildOfficerPopupHtml(officer)) + .addTo(map); + + highlightPopup.on("remove", () => { clearOfficerHighlight(); }); + + // Pan only on first selection + map.panTo(latlng, { animate: true, duration: 0.5 }); + } + + // Always update popup: position + full content (so all live data refreshes) + if (highlightPopup) { + highlightPopup.setLatLng(latlng); + highlightPopup.setContent(buildOfficerPopupHtml(officer)); + } + } - // New patrol form - let newPatrolName = $state(""); + let newPatrolName = $state(""); let newPatrolColor = $state("#38bdf8"); let showCreateForm = $state(false); - // Edit patrol name - let editingPatrolId = $state(null); + let editingPatrolId = $state(null); let editingPatrolName = $state(""); const PATROL_COLORS = [ @@ -99,64 +225,309 @@ "#ef4444", "#eab308", "#ec4899", "#14b8a6" ]; - // ─── Helpers ────────────────────────────────────────────────────────────── - const offsetX = 13; - const offsetY = 5; + // ── Zone drawing state ──────────────────────────────────────────────────── + let drawingPatrolId = $state(null); + let drawPoints = $state([]); + let drawPolyline: L.Polyline | null = null; + let drawPolygon: L.Polygon | null = null; + let drawMarkers: L.CircleMarker[] = []; + // cursorMarker removed – using DOM dot instead (see createCursorDot) + const zonePolygons = new globalThis.Map(); + + // Marker pools for recycling — keyed by citizenid / plate so existing markers + // are moved (setLatLng/setIcon) instead of cleared and rebuilt every refresh. + const bodycamMarkers = new globalThis.Map(); + const vehicleMarkers = new globalThis.Map(); + + const offsetX = 40; + const offsetY = 31; function toMapLatLng(coords: { x: number; y: number }) { return [coords.y - offsetY, coords.x + offsetX]; } + function toGtaCoords(latlng: L.LatLng): GtaPoint { + return { x: latlng.lng - offsetX, y: latlng.lat + offsetY }; + } + + // ── Zone rendering ──────────────────────────────────────────────────────── + function renderAllZones() { + if (!map) return; + for (const { poly, label } of zonePolygons.values()) { poly.remove(); label.remove(); } + zonePolygons.clear(); + zoneLayer.clearLayers(); + if (!showZones) return; + for (const patrol of patrols) { + if (patrol.zonePoints && patrol.zonePoints.length >= 3) renderZone(patrol); + } + } + + function renderZone(patrol: Patrol) { + if (!map || !patrol.zonePoints || patrol.zonePoints.length < 3) return; + const latlngs = patrol.zonePoints.map(pt => toMapLatLng(pt) as L.LatLng); + const poly = L.polygon(latlngs, { + color: patrol.color, weight: 2, opacity: 0.85, + fillColor: patrol.color, fillOpacity: 0.12, + dashArray: "6 4", className: "patrol-zone-poly", + }).addTo(zoneLayer); + const center = poly.getBounds().getCenter(); + const label = L.marker(center, { + icon: L.divIcon({ + className: "", + html: `
${patrol.name}
`, + iconSize: [null as any, null as any], iconAnchor: [0, 0], + }), + interactive: false, zIndexOffset: -200, + }).addTo(zoneLayer); + zonePolygons.set(patrol.id, { poly, label }); + } + + function removeZoneById(id: string) { + const e = zonePolygons.get(id); + if (e) { e.poly.remove(); e.label.remove(); zonePolygons.delete(id); } + } + + function refreshZoneForPatrol(patrol: Patrol) { + removeZoneById(patrol.id); + if (showZones && patrol.zonePoints && patrol.zonePoints.length >= 3) renderZone(patrol); + } + + // ── Zone drawing ────────────────────────────────────────────────────────── + function getDrawColor() { + return patrols.find(p => p.id === drawingPatrolId)?.color ?? "#38bdf8"; + } + + // ── DOM cursor dot (bypasses CSS zoom coordinate issues) ───────────────── + let cursorDotEl: HTMLDivElement | null = null; + + function createCursorDot() { + removeCursorDot(); + cursorDotEl = document.createElement("div"); + cursorDotEl.className = "draw-cursor-dot"; + cursorDotEl.style.setProperty("--dot-color", getDrawColor()); + document.body.appendChild(cursorDotEl); + } + + function moveCursorDot(clientX: number, clientY: number) { + if (!cursorDotEl) return; + cursorDotEl.style.left = `${clientX}px`; + cursorDotEl.style.top = `${clientY}px`; + } + + function removeCursorDot() { + cursorDotEl?.remove(); + cursorDotEl = null; + } + + function startDrawing(patrolId: string) { + if (!map || !canEditPatrols) return; + stopDrawing(false); + drawingPatrolId = patrolId; + drawPoints = []; + // Hide native cursor, use our DOM dot instead (immune to CSS zoom offset) + mapContainer?.classList.add("map-cursor-none"); + createCursorDot(); + map.on("mousemove", onDrawMouseMove); + map.on("click", onDrawClick); + globalNotifications.info("Click to place points • Enter to finish • Backspace to undo • Esc to cancel"); + } + + // The MDT is commonly scaled by CSS `zoom` (and sometimes `transform: scale()`) + // on a parent for resolution-independent UI. We must undo that scale to map a + // mouse position into Leaflet's own container-pixel space, otherwise placed + // points drift by a constant on-screen offset. We read the declared zoom / + // transform off the ancestor chain — that's reliable across Chromium versions, + // unlike deriving it from offsetWidth (which itself becomes scaled under `zoom`). + function getAncestorScale(): number { + let el: HTMLElement | null = mapContainer; + let s = 1; + while (el) { + const cs = getComputedStyle(el); + const zoomStr = (cs as any).zoom as string | undefined; + if (zoomStr && zoomStr !== "" && zoomStr !== "normal") { + const zv = parseFloat(zoomStr); + if (!isNaN(zv) && zv !== 1) s *= zv; + } + const t = cs.transform; + if (t && t !== "none") { + try { + const m = new DOMMatrixReadOnly(t); + if (m.a && !isNaN(m.a) && m.a !== 1) s *= m.a; // horizontal scale + } catch { /* unparseable transform — ignore */ } + } + el = el.parentElement; + } + return s; + } + + function mouseEventToLatLng(e: L.LeafletMouseEvent): L.LatLng { + if (!map || !mapContainer) return e.latlng; + const oe = e.originalEvent as MouseEvent; + const rect = mapContainer.getBoundingClientRect(); + const scale = getAncestorScale(); + + // Residual constant correction (in container px) for a getBoundingClientRect + // quirk under CSS `zoom`: the measured rect origin is off by a fixed amount + // for this layout. NOTE: these are NOT the GTA<->map offsetX/offsetY above — + // they're a pixel fudge tied to the MDT's current zoom factor and the map's + // placement in the layout. Re-tune if either of those changes. + const clickFudgeX = 40; + const clickFudgeY = 30; + + // rect + clientX/Y are on-screen (scaled) px; Leaflet's container point is + // in unscaled layout px, so undo the scale on the offset from the edge. + const x = ((oe.clientX - rect.left) / scale) - clickFudgeX; + const y = ((oe.clientY - rect.top) / scale) - clickFudgeY; + return map.containerPointToLatLng(L.point(x, y)); + } + + function onDrawMouseMove(e: L.LeafletMouseEvent) { + if (!map) return; + const latlng = mouseEventToLatLng(e); + // Position cursor dot directly using native mouse coords — no zoom distortion + const oe = e.originalEvent as MouseEvent; + moveCursorDot(oe.clientX, oe.clientY); + if (drawPoints.length > 0) { + const pts = [...drawPoints, latlng]; + if (!drawPolyline) { + drawPolyline = L.polyline(pts, { color: getDrawColor(), weight: 2, opacity: 0.7, dashArray: "5 4", interactive: false }).addTo(map); + } else { drawPolyline.setLatLngs(pts); } + if (drawPoints.length >= 2) { + const closed = [...drawPoints, latlng, drawPoints[0]]; + if (!drawPolygon) { + drawPolygon = L.polygon(closed, { color: getDrawColor(), weight: 1.5, opacity: 0.5, fillColor: getDrawColor(), fillOpacity: 0.08, interactive: false, dashArray: "5 4" }).addTo(map); + } else { drawPolygon.setLatLngs(closed); } + } + } + } + + function onDrawClick(e: L.LeafletMouseEvent) { + if (!map) return; + const latlng = mouseEventToLatLng(e); + if (drawPoints.length >= 3) { + const fp = map.latLngToContainerPoint(drawPoints[0]); + const np = map.latLngToContainerPoint(latlng); + if (Math.hypot(fp.x - np.x, fp.y - np.y) < 14) { finishDrawing(); return; } + } + drawPoints = [...drawPoints, latlng]; + drawMarkers.push(L.circleMarker(latlng, { radius: 4, color: getDrawColor(), fillColor: "#fff", fillOpacity: 1, weight: 2, interactive: false }).addTo(map)); + } + + async function finishDrawing() { + if (drawPoints.length < 3) { globalNotifications.error("Need at least 3 points."); return; } + const id = drawingPatrolId; + if (!id) return; + const gtaPoints = drawPoints.map(toGtaCoords); + stopDrawing(false); + patrols = patrols.map(p => p.id === id ? { ...p, zonePoints: gtaPoints } : p); + const patrol = patrols.find(p => p.id === id); + if (patrol) refreshZoneForPatrol(patrol); + try { await fetchNui(NUI_EVENTS.MAP.SET_PATROL_ZONE, { id, points: gtaPoints }, { success: true }); } + catch { globalNotifications.error("Failed to save zone."); } + } + + async function clearZone(id: string) { + patrols = patrols.map(p => p.id === id ? { ...p, zonePoints: null } : p); + removeZoneById(id); + try { await fetchNui(NUI_EVENTS.MAP.SET_PATROL_ZONE, { id, points: null }, { success: true }); } + catch { globalNotifications.error("Failed to clear zone."); } + } + + function stopDrawing(notify = true) { + if (!map) return; + map.off("mousemove", onDrawMouseMove); + map.off("click", onDrawClick); + drawPolyline?.remove(); drawPolyline = null; + drawPolygon?.remove(); drawPolygon = null; + removeCursorDot(); + for (const m of drawMarkers) m.remove(); + drawMarkers = []; + mapContainer?.classList.remove("map-cursor-none"); + drawingPatrolId = null; + drawPoints = []; + if (notify) globalNotifications.info("Zone drawing cancelled."); + } + + function onKeyDown(e: KeyboardEvent) { + if (!drawingPatrolId) return; + if (e.key === "Enter") { e.preventDefault(); finishDrawing(); } + else if (e.key === "Escape") { e.preventDefault(); stopDrawing(true); } + else if (e.key === "Backspace" && drawPoints.length > 0) { + e.preventDefault(); + drawMarkers[drawMarkers.length - 1]?.remove(); + drawMarkers.pop(); + drawPoints = drawPoints.slice(0, -1); + // Update or remove preview lines after undo + if (drawPoints.length === 0) { + drawPolyline?.remove(); drawPolyline = null; + drawPolygon?.remove(); drawPolygon = null; + } else if (drawPoints.length === 1) { + drawPolygon?.remove(); drawPolygon = null; + } + // Polyline will be redrawn on next mousemove automatically + } + } function getTrackConfig(kind: "vehicle" | "bodycam") { if (kind === "vehicle") return { color: "#f97316", fill: "#fb923c", label: "V" }; return { color: "#a855f7", fill: "#c084fc", label: "B" }; } - function createMarker( + // Builds just the divIcon for a tracker. Split out from createMarker so we can + // reuse it on existing markers via setIcon() during recycling. + function makeTrackIcon( kind: "vehicle" | "bodycam", - coords: { x: number; y: number }, - label: string, heading?: number, - patrolColor?: string - ) { + patrolColor?: string, + cached = false + ): L.DivIcon { const config = getTrackConfig(kind); const dotColor = patrolColor ?? config.fill; const borderColor = patrolColor ? patrolColor : config.color; - const latLng = toMapLatLng(coords); const rotation = heading != null ? 360 - heading : 0; const hasHeading = heading != null; + const cachedClass = cached ? " tracking-cached" : ""; if (iconStyle === "badge") { - return L.marker(latLng as any, { - icon: L.divIcon({ - className: "", - html: ` -
-
- ${config.label} -
- ${hasHeading ? `
` : ""} -
- `, - iconSize: [28, 28], - iconAnchor: [14, 14], - }), - }).bindTooltip(label, { direction: "top", offset: [0, -14] }); - } - - return L.marker(latLng as any, { - icon: L.divIcon({ + return L.divIcon({ className: "", html: ` -
-
+
+
+ ${config.label} +
${hasHeading ? `
` : ""}
`, - iconSize: [20, 20], - iconAnchor: [10, 10], - }), - }).bindTooltip(label, { direction: "top", offset: [0, -10] }); + iconSize: [28, 28], + iconAnchor: [14, 14], + }); + } + + return L.divIcon({ + className: "", + html: ` +
+
+ ${hasHeading ? `
` : ""} +
+ `, + iconSize: [20, 20], + iconAnchor: [10, 10], + }); + } + + function createMarker( + kind: "vehicle" | "bodycam", + coords: { x: number; y: number }, + label: string, + heading?: number, + patrolColor?: string, + cached = false + ) { + const offset: [number, number] = iconStyle === "badge" ? [0, -14] : [0, -10]; + return L.marker(toMapLatLng(coords) as any, { + icon: makeTrackIcon(kind, heading, patrolColor, cached), + }).bindTooltip(label, { direction: "top", offset }); } function normalizeCoords(raw: any) { @@ -174,7 +545,15 @@ return officers.filter(o => !patrols.some(p => p.memberIds.includes(o.citizenid))); } - // ─── Patrol map labels ──────────────────────────────────────────────────── + // Case-insensitive filter over name / callsign / rank for the sidebar search. + function filterOfficers(list: Bodycam[]): Bodycam[] { + const q = officerSearch.trim().toLowerCase(); + if (!q) return list; + return list.filter(o => + [o.name, o.callsign, o.rank].some(v => String(v ?? "").toLowerCase().includes(q)) + ); + } + function refreshPatrolLabels() { patrolLayer.clearLayers(); if (!showPatrols) return; @@ -183,7 +562,6 @@ const members = officers.filter(o => patrol.memberIds.includes(o.citizenid)); if (members.length === 0) continue; - // Centroid berechnen const centroid = members.reduce( (acc, o) => ({ x: acc.x + o.coords.x, y: acc.y + o.coords.y }), { x: 0, y: 0 } @@ -191,8 +569,6 @@ centroid.x /= members.length; centroid.y /= members.length; - // Place label at the member closest to the centroid - const anchor = members.reduce((closest, o) => { const dx = o.coords.x - centroid.x; const dy = o.coords.y - centroid.y; @@ -202,13 +578,12 @@ }); const latLng = toMapLatLng(anchor.coords); - L.marker(latLng as any, { icon: L.divIcon({ className: "", html: `
${patrol.name}
`, iconSize: [null as any, null as any], - iconAnchor: [0, 24], // offset upward above the marker + iconAnchor: [0, 24], }), interactive: false, zIndexOffset: -100, @@ -216,7 +591,6 @@ } } - // ─── Refresh tracking ───────────────────────────────────────────────────── async function refreshTracking() { if (!map || !tabVisible) return; if (isEnvBrowser()) return; @@ -229,7 +603,6 @@ 3000, ); - // Bei Fehler oder leerem Response bestehende Daten behalten const success = (response as any).success; if (success === false) return; @@ -237,54 +610,115 @@ const bodycams = (data as any).bodycams; const vehicles = (data as any).vehicles; - // Nur updaten wenn der Server wirklich Daten geliefert hat if (!Array.isArray(bodycams) && !Array.isArray(vehicles)) return; - vehicleLayer.clearLayers(); - bodycamLayer.clearLayers(); - const freshOfficers: Bodycam[] = []; + const seenBodycams = new Set(); for (const bodycam of bodycams || []) { const coords = normalizeCoords((bodycam as any).coords); if (!coords) continue; + // A missing citizenid forces a random fallback id below, which makes + // this officer's marker flicker between refreshes. Surface it for devs. + if (!bodycam.citizenid) console.warn("[MDT] bodycam without citizenid; marker may flicker:", bodycam.name); + const bc: Bodycam = { citizenid: bodycam.citizenid ?? bodycam.name ?? String(Math.random()), name: bodycam.name ?? "", callsign: bodycam.callsign, rank: bodycam.rank, coords: { x: coords.x, y: coords.y, z: bodycam.coords?.z ?? 0 }, + inVehicle: (bodycam as any).inVehicle ?? false, heading: bodycam.heading, }; freshOfficers.push(bc); + seenBodycams.add(bc.citizenid); + + const patrol = getOfficerPatrol(bc.citizenid); + const label = `${[bc.rank, bc.callsign].filter(Boolean).join(" | ")}${bc.name ? " | " + bc.name : ""}`; + const color = patrol?.color ?? "#6b7280"; + const latLng = toMapLatLng(coords) as any; + + // Recycle existing markers (move + restyle) instead of clearing the + // whole layer and rebuilding every divIcon each refresh. + const existing = bodycamMarkers.get(bc.citizenid); + if (existing) { + existing.setLatLng(latLng); + existing.setIcon(makeTrackIcon("bodycam", bodycam.heading, color)); + existing.setTooltipContent(label); + } else { + const m = createMarker("bodycam", coords, label, bodycam.heading, color); + m.addTo(bodycamLayer); + bodycamMarkers.set(bc.citizenid, m); + } + } - if (showBodycams) { - const patrol = getOfficerPatrol(bc.citizenid); - const label = `${[bc.rank, bc.callsign].filter(Boolean).join(" | ")}${bc.name ? " | " + bc.name : ""}`; - const color = patrol?.color ?? "#6b7280"; - createMarker("bodycam", coords, label, bodycam.heading, color).addTo(bodycamLayer); + // Drop markers for officers no longer present + for (const [id, m] of bodycamMarkers) { + if (!seenBodycams.has(id)) { + bodycamLayer.removeLayer(m); + bodycamMarkers.delete(id); } } officers = freshOfficers; - if (showVehicles) { - for (const vehicle of vehicles || []) { - const coords = normalizeCoords((vehicle as any).coords); - if (!coords) continue; - const label = `${(vehicle as any).plate || ""}`.trim(); - createMarker("vehicle", coords, label, (vehicle as any).heading).addTo(vehicleLayer); + // Pan to own position once per MDT open + if (!centeredOnSelf && map && ownCitizenId) { + const self = freshOfficers.find(o => o.citizenid === ownCitizenId); + if (self) { + centeredOnSelf = true; + map.setView(toMapLatLng(self.coords) as L.LatLngExpression, 5, { animate: false }); + } + } + + // Keep highlight in sync as the officer moves; drop it if they go off-duty. + if (selectedOfficerId) { + if (officers.some(o => o.citizenid === selectedOfficerId)) { + highlightOfficerOnMap(selectedOfficerId); + } else { + clearOfficerHighlight(); + } + } + + // Vehicles — same recycling approach. `cached` (parked / last-known) + // vehicles come from the server's vehicleCache and are rendered dimmed. + const seenVehicles = new Set(); + for (const vehicle of vehicles || []) { + const coords = normalizeCoords((vehicle as any).coords); + if (!coords) continue; + const plate = `${(vehicle as any).plate || ""}`.trim(); + const cached = (vehicle as any).cached === true; + const label = cached ? `${plate || "Vehicle"} (Parked)` : plate; + const key = plate || `v:${coords.x.toFixed(1)},${coords.y.toFixed(1)}`; + seenVehicles.add(key); + const latLng = toMapLatLng(coords) as any; + + const existing = vehicleMarkers.get(key); + if (existing) { + existing.setLatLng(latLng); + existing.setIcon(makeTrackIcon("vehicle", (vehicle as any).heading, undefined, cached)); + existing.setTooltipContent(label); + } else { + const m = createMarker("vehicle", coords, label, (vehicle as any).heading, undefined, cached); + m.addTo(vehicleLayer); + vehicleMarkers.set(key, m); + } + } + for (const [key, m] of vehicleMarkers) { + if (!seenVehicles.has(key)) { + vehicleLayer.removeLayer(m); + vehicleMarkers.delete(key); } } refreshPatrolLabels(); } catch { - // Timeout oder Netzwerkfehler – bestehende Officer/Marker behalten + // keep existing } } - // ─── Mouse-based Drag System (kein HTML5 draggable – CEF-kompatibel) ────── type DragKind = "officer" | "patrol"; type DragState = { kind: DragKind; @@ -300,7 +734,6 @@ let dragOverPatrolSortId = $state(null); let isDragging = $state(false); - // Ghost element for visual drag feedback let ghostEl: HTMLDivElement | null = null; function createGhost(label: string, kind: DragKind, x: number, y: number) { @@ -324,7 +757,6 @@ ghostEl = null; } - // Patrol-Card-Elemente per data-patrol-id finden function getPatrolIdFromPoint(x: number, y: number): string | null { const els = document.elementsFromPoint(x, y); for (const el of els) { @@ -344,7 +776,6 @@ if (!drag) return; if (!drag.active) { - // Only count as drag after 5px movement const dx = e.clientX - drag.x; const dy = e.clientY - drag.y; if (Math.sqrt(dx*dx + dy*dy) < 5) return; @@ -375,14 +806,12 @@ if (pid) { assignOfficer(drag.id, pid); } else { - // Auf Officers-Panel losgelassen → aus Streife entfernen const el = document.elementFromPoint(e.clientX, e.clientY); if (el?.closest(".panel-officers")) { removeFromPatrol(drag.id); } } } else if (drag.kind === "patrol" && pid && pid !== drag.id) { - // Streifen sortieren const arr = [...patrols]; const fromIdx = arr.findIndex(p => p.id === drag!.id); const toIdx = arr.findIndex(p => p.id === pid); @@ -401,12 +830,13 @@ dragOverPatrolId = null; dragOverPatrolSortId = null; } + function handleNuiMessage(event: MessageEvent) { const { type, data } = event.data ?? {}; if (type === "setVisible") { if (data?.visible === true) { - // Short delay to ensure MDTOpen is set in Lua client + centeredOnSelf = false; // re-center each time MDT opens setTimeout(() => { refreshTracking(); loadPatrols(); @@ -415,8 +845,12 @@ return; } + if (type === "setLocalCitizenId") { + if (typeof data?.citizenid === "string") ownCitizenId = data.citizenid; + return; + } + if (type === "mapUiState") { - // Lua client state overrides localStorage (authoritative after resource restart) if (typeof data?.sidebarOpen === "boolean") { sidebarOpen = data.sidebarOpen; localStorage.setItem("mdt_map_sidebar", String(sidebarOpen)); } if (typeof data?.officersOpen === "boolean") { officersOpen = data.officersOpen; localStorage.setItem("mdt_map_officers", String(officersOpen)); } if (typeof data?.patrolsOpen === "boolean") { patrolsOpen = data.patrolsOpen; localStorage.setItem("mdt_map_patrols", String(patrolsOpen)); } @@ -424,17 +858,18 @@ } if (type === "syncPatrols") { - // Server sends sorted array patrols = Array.isArray(data) ? data as Patrol[] : Object.values(data as Record); refreshPatrolLabels(); - // Trigger animation for all clients based on server hint + renderAllZones(); const msg = event.data as any; if (msg.action === "assigned" && msg.citizenid) flashAssigned(msg.citizenid); if (msg.action === "removed" && msg.citizenid) flashRemoved(msg.citizenid); + return; } + + } - // ─── Assignment animation state ─────────────────────────────────────────── let recentlyAssigned = $state>(new Set()); let recentlyRemoved = $state>(new Set()); @@ -451,6 +886,7 @@ recentlyRemoved = new Set([...recentlyRemoved].filter(id => id !== citizenid)); }, 700); } + async function loadPatrols() { if (isEnvBrowser()) return; try { @@ -458,12 +894,12 @@ const data = (res as any).data ?? res; patrols = Array.isArray(data) ? data as Patrol[] : Object.values(data as Record); refreshPatrolLabels(); + renderAllZones(); } catch { globalNotifications.error("Failed to load patrols"); } } - // Doppelter Name? function patrolNameExists(name: string, excludeId?: string) { return patrols.some(p => p.name.toLowerCase() === name.toLowerCase() && p.id !== excludeId); } @@ -478,15 +914,17 @@ const id = crypto.randomUUID(); try { await fetchNui(NUI_EVENTS.MAP.CREATE_PATROL, { id, name, color: newPatrolColor }, { success: true }); - } catch { /* Server broadcasts syncPatrols */ } + } catch { } newPatrolName = ""; showCreateForm = false; } async function deletePatrol(id: string) { + if (drawingPatrolId === id) stopDrawing(false); + removeZoneById(id); try { await fetchNui(NUI_EVENTS.MAP.DELETE_PATROL, { id }, { success: true }); - } catch { /* Server broadcastet syncPatrols */ } + } catch { } } async function renamePatrolOnServer(id: string, name: string) { @@ -496,7 +934,7 @@ } try { await fetchNui(NUI_EVENTS.MAP.RENAME_PATROL, { id, name }, { success: true }); - } catch { /* Server broadcastet syncPatrols */ } + } catch { } } async function assignOfficer(officerId: string, patrolId: string) { @@ -511,7 +949,6 @@ } catch { } } - // Move patrol up/down in the list function movePatrol(id: string, dir: -1 | 1) { const idx = patrols.findIndex(p => p.id === id); if (idx < 0) return; @@ -527,7 +964,6 @@ fetchNui(NUI_EVENTS.MAP.REORDER_PATROLS, { ids: arr.map(p => p.id) }, { success: true }).catch(() => {}); } - // ─── NUI Message Listener (Server → Client → NUI sync) ─────────────────── function handleVisibilityChange() { tabVisible = !document.hidden; } @@ -541,6 +977,7 @@ toggle(vehicleLayer, showVehicles); toggle(bodycamLayer, showBodycams); toggle(patrolLayer, showPatrols); + toggle(zoneLayer, showZones); } function getCustomCRS() { @@ -559,6 +996,7 @@ }); } + // IDENTICAL to original – no changes function initializeMap() { if (mapInitialized) return; mapInitialized = true; @@ -578,7 +1016,7 @@ L.control.zoom({ position: "topright" }).addTo(map); const bounds = getMapBounds(map); - map.setView([-300, -1500], 4); + map.setView([-300, -1500], 3); map.setMaxBounds(bounds); map.attributionControl.setPrefix(false); @@ -592,7 +1030,8 @@ vehicleLayer = L.layerGroup().addTo(map); bodycamLayer = L.layerGroup().addTo(map); - patrolLayer = L.layerGroup().addTo(map); + patrolLayer = L.layerGroup().addTo(map); + zoneLayer = L.layerGroup().addTo(map); syncLayerVisibility(); refreshTracking(); @@ -605,11 +1044,13 @@ return new LatLngBounds(sw, ne); } + // IDENTICAL to original onMount – no changes onMount(() => { document.addEventListener("visibilitychange", handleVisibilityChange); window.addEventListener("message", handleNuiMessage); window.addEventListener("mousemove", onGlobalMouseMove); window.addEventListener("mouseup", onGlobalMouseUp); + window.addEventListener("keydown", onKeyDown); initializeMap(); loadPatrols(); }); @@ -619,23 +1060,25 @@ window.removeEventListener("message", handleNuiMessage); window.removeEventListener("mousemove", onGlobalMouseMove); window.removeEventListener("mouseup", onGlobalMouseUp); + window.removeEventListener("keydown", onKeyDown); + if (drawingPatrolId) stopDrawing(false); removeGhost(); if (map) { map.remove(); map = null; mapInitialized = false; } if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } + bodycamMarkers.clear(); + vehicleMarkers.clear(); }); $effect(() => { syncLayerVisibility(); }); $effect(() => { iconStyle; refreshTracking(); }); $effect(() => { showPatrols; refreshPatrolLabels(); }); + $effect(() => { showZones; renderAllZones(); }); -
-
Tracking -
+
-
-
Style
@@ -660,11 +1105,10 @@
-
-
Vehicle + Parked Unassigned {#each patrols.filter(p => p.memberIds.length > 0) as patrol} {patrol.name} @@ -672,18 +1116,31 @@
- + {#if drawingPatrolId} + {@const drawPatrol = patrols.find(p => p.id === drawingPatrolId)} +
+
+ + Drawing zone for {drawPatrol?.name} +
+
+ Click Place point  ·  + Enter Finish  ·  + Undo  ·  + Esc Cancel +
+
{drawPoints.length} point{drawPoints.length !== 1 ? "s" : ""}{drawPoints.length >= 3 ? " ✓" : ""}
+
+ + +
+
+ {/if} +
- {#if canViewPatrols} - -