Tablet Extensions
Your own resources can add panels, pages and actions to the DOJ tablet without touching kartik-dojjob. A licensing, permits or business registry script can show its records on a citizen's profile and let DOJ staff act on them from the tablet.
One registration can give:
- a panel on the citizen profile (People page)
- its own page in the tablet menu, under More, with a search box
- actions with a small form (dropdowns, dates, numbers, text, a citizen picker). Every action asks for a legal reason and can be linked to a court case. Your handler runs on the server, in your resource.
Everything is server side. The full reference is also at the top of bridge/integrations/server/extensions.lua (open file).
Who sees what
permissionon the extension: the DOJ permission a grade needs to see the panel and page (defaultcasesView).permissionon each action: the DOJ permission a grade needs to use it, for examplelicenses,permitsorverdicts.- Grades are set in the Admin Panel and on the tablet's Staff page, see Permissions. A grade that can see the panel but has none of the action permissions gets a read-only view.
- Optional
canView(src)andcanUse(src, ctx)functions let your resource add its own rules (for example only judges for judicial orders). - Buttons a player may not use are not sent to their tablet, and every action is checked again on the server before your handler runs.
- The DOJ tablet is for DOJ staff only. Other departments can read your data with your own exports or their MDT.
Register
Register when your resource starts, and again when kartik-dojjob restarts:
local function register()
exports['kartik-dojjob']:RegisterExtension('licensing', {
label = 'Licensing',
description = 'Licences, permits and judicial restrictions',
icon = 'licence', -- licence | permit | business | shield | file | gavel | car | weapon | scroll
permission = 'casesView', -- needed to see it
-- panel on the citizen profile; return nil or false to hide it for this citizen
profile = function(src, citizenid)
return { records = GetRecordsFor(citizenid), empty = 'No licences on file' }
end,
-- optional own page; query is the search text ('' when empty)
section = function(src, query)
return { records = SearchRecords(query) }
end,
actions = {
suspend = {
label = 'Judicial suspension',
permission = 'verdicts',
scope = 'record',
danger = true,
requireCase = true,
fields = {
{ name = 'until', label = 'Suspended until', type = 'date', required = true },
},
handler = function(src, ctx)
-- look ctx.recordId up in YOUR data and check it belongs to ctx.citizenid
local ok = SuspendRecord(ctx.recordId, ctx.citizenid, ctx.values['until'], ctx.reason, ctx.caseId, ctx.official.name)
if not ok then return false, 'This licence is not active' end
return true, 'Licence suspended'
end,
},
},
})
end
AddEventHandler('onResourceStart', function(resource)
if resource == 'kartik-dojjob' then register() end
end)
CreateThread(function()
if GetResourceState('kartik-dojjob') == 'started' then register() end
end)RegisterExtension returns true, or false, error (invalid_id, invalid_definition, invalid_permission, nothing_to_show). Problems are also printed in the server console. Registering the same id again replaces it. Registrations are removed when your resource stops.
Extension options
| Option | Type | Notes |
|---|---|---|
label | string | Title of the panel and page |
description | string / nil | Small line under the title |
icon | string / nil | licence, permit, business, shield, file, gavel, car, weapon or scroll |
permission | string | DOJ permission key needed to see it. Default casesView |
canView | function(src) / nil | Return false to hide it from this player |
profile | function(src, citizenid) / nil | Data for the citizen profile panel |
section | function(src, query) / nil | Data for the extension's own page |
actions | table / nil | { [actionId] = action }, up to 12 |
An extension needs profile, section or both.
Action options
| Option | Type | Notes |
|---|---|---|
label | string | Button text |
permission | string | Required. DOJ permission key needed to use it |
scope | string | 'citizen': button on the profile panel. 'record': button on a record (list the action id in the record's actions). 'section': button on the extension's page. Default 'citizen' |
danger | boolean | Red button |
requireReason | boolean | Default true: a legal reason must be written |
requireCase | boolean | Default false: a case number is always optional, this makes it required |
confirm | string / nil | Ask "are you sure" with this text first |
fields | table / nil | Form fields, see below (up to 20) |
canUse | function(src, ctx) / nil | Return false to refuse this player |
handler | function(src, ctx) | Required. Return true, message when done or false, message to refuse. The message is shown to the player |
Fields
| Option | Notes |
|---|---|
name | Key in ctx.values (letters, numbers, _) |
label | Field label |
type | text, textarea, number, date, select, checkbox or citizen |
required | true to make it required |
options | For select: { { value = 'c1', label = 'Class 1' }, ... } or { 'c1', 'c2' } |
min, max | For number |
placeholder, default | Optional |
Values arrive cleaned: date is a unix time (seconds), number a number, checkbox a boolean, select one of its options, citizen a citizen id that exists. Anything else in the form is dropped.
ctx
| Field | Notes |
|---|---|
action | Action id |
citizenid | The profile's citizen, the record's citizenid on a page, or the first citizen field |
recordId | The record's id (record actions) |
values | Form values by field name |
reason | Legal reason, or nil when not required and left empty |
caseId, case | Linked court case (case = { id, title }), checked to exist |
official | { source, name, identifier, job, grade, gradeLabel } of the DOJ member |
at | Unix time |
The tablet sends citizenid and recordId, so always look the record up in your own data and check it belongs to that citizen before changing it.
When the handler returns true, the action goes to the DOJ audit log (and your log webhook). When a case was given, a line is added to the case transcript, for example Licensing - Judicial suspension (DL-1042): reason.
Data your profile / section returns
return {
records = {
{
id = 'DL-1042', -- comes back as ctx.recordId
title = 'Driver licence',
subtitle = 'Class C',
status = 'suspended', -- active | suspended | revoked | expired | pending | approved | denied, or your own word
tone = nil, -- optional colour: ok | minor | moderate | critical | neutral | accent
citizenid = 'ABC12345', -- optional: on a page, links to the citizen's profile
citizen = 'John Doe',
caseId = 12, -- optional: links to the case
fields = { { label = 'Issued', value = '2026-10-01' }, { label = 'Issued by', value = 'COC' } },
history = { { at = 1760000000, text = 'Suspended, case #12', by = 'Judge Smith' } },
actions = { 'suspend', 'lift' }, -- record actions shown on this record
},
},
summary = { { label = 'Businesses', value = '2' } }, -- optional chips under the title
empty = 'No licences on file', -- optional
}Up to 100 records, 20 fields and 30 history lines per record are shown.
Refresh and remove
exports['kartik-dojjob']:RefreshExtension() -- open tablets reload extension panels and pages
exports['kartik-dojjob']:UnregisterExtension('licensing') -- only the resource that registered itCall RefreshExtension() after your own data changes (for example when another department issues a licence) so DOJ tablets that are open show it straight away. Tablets also refresh after every extension action.