big update all menus

This commit is contained in:
Wian Drs
2026-08-07 09:16:13 +07:00
parent 75342dc7b7
commit e7575ba1c0
101 changed files with 3451 additions and 89 deletions
@@ -9,6 +9,11 @@ use App\Http\Requests\Ticket\UpdateTicketRequest;
use App\Http\Resources\Ticket\TicketResource;
use App\Models\Ticket;
use App\Services\Ticket\TicketService;
use App\Models\Customer;
use App\Models\TicketType;
use App\Models\User;
use App\Models\TicketMaterial;
use Illuminate\Http\Request;
class TicketController extends ApiController
{
@@ -19,7 +24,13 @@ class TicketController extends ApiController
public function index(IndexTicketRequest $request)
{
$filters = $request->validated();
$tickets = Ticket::query()
$tenantId = $request->attributes->get('tenant_id');
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$tickets = Ticket::query()->with(['ticketType:id,code,name,need_approval,require_photo,require_material','customer:id,customer_code,name','tenant:id,tenant_name','creator:id,name','assignments.user:id,name'])
->when(! $request->user()->isMasterAdmin(), fn ($query) => $query->where('tenant_id', $tenantId))
->when($request->user()->isMasterAdmin() && $tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->when(! empty($filters['assigned_to_me']), fn ($query) => $query->whereHas('assignments', fn ($assignment) => $assignment->where('user_id', $request->user()->id)))
->when(
! empty($filters['search']),
fn ($query) => $query->where(function ($searchQuery) use ($filters) {
@@ -44,7 +55,7 @@ class TicketController extends ApiController
! empty($filters['customer_id']),
fn ($query) => $query->where('customer_id', $filters['customer_id'])
)
->latest()
->orderBy($sortBy, $sortDirection)
->paginate($filters['per_page'] ?? 10);
return $this->successPaginated(
@@ -57,7 +68,7 @@ class TicketController extends ApiController
public function store(StoreTicketRequest $request)
{
$ticket = $this->service->create(
$request->validated()
$request->validated(), $request->user(), $request->attributes->get('tenant_id')
);
return $this->created(
@@ -66,10 +77,11 @@ class TicketController extends ApiController
);
}
public function show(Ticket $ticket)
public function show(Request $request, Ticket $ticket)
{
$this->authorizeTenant($request, $ticket);
return $this->success(
new TicketResource($ticket)
new TicketResource($ticket->load(['ticketType','customer','tenant','creator','assignments.user','logs','materials','attachments']))
);
}
@@ -77,6 +89,7 @@ class TicketController extends ApiController
UpdateTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTenant($request, $ticket);
$ticket = $this->service->update(
$ticket,
$request->validated()
@@ -88,12 +101,72 @@ class TicketController extends ApiController
);
}
public function destroy(Ticket $ticket)
public function destroy(Request $request, Ticket $ticket)
{
$this->authorizeTenant($request, $ticket);
$this->service->delete($ticket);
return $this->deleted(
'Ticket berhasil dibatalkan'
);
}
public function options(Request $request)
{
$tenantId = $request->attributes->get('tenant_id');
return $this->success([
'ticket_types' => TicketType::orderBy('name')->get(['id','code','name','need_approval','require_photo','require_material']),
'customers' => Customer::when(! $request->user()->isMasterAdmin() || $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))->orderBy('name')->limit(500)->get(['id','customer_code','name','tenant_id']),
'technicians' => User::whereHas('userTenants', fn ($q) => $q->where('tenant_id', $tenantId))->orderBy('name')->get(['id','name','email']),
'installation_setting' => $tenantId
? \App\Models\TenantInstallationSetting::firstOrCreate(['tenant_id' => $tenantId], ['activation_mode' => 'list_order'])
: null,
'package_profiles'=>\App\Models\NasPackageProfile::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
'nas_mikrotiks'=>\App\Models\NasMikrotik::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
'billing_profiles'=>\App\Models\BillingProfile::where('tenant_id',$tenantId)->where('status','active')->orderBy('name')->get(['id','name']),
'topology_odps'=>\App\Models\TopologyNode::where('tenant_id',$tenantId)->whereHas('deviceType',fn($q)=>$q->whereRaw('UPPER(code) = ?',['ODP']))->orderBy('name')->get(['id','code','name']),
], 'Pilihan ticket berhasil diambil');
}
public function storeMaterial(Request $request, Ticket $ticket)
{
$this->authorizeTenant($request,$ticket);
$data=$request->validate(['technician_material_id'=>['nullable','required_without:technician_consumable_id','integer','exists:technician_materials,id'],'technician_consumable_id'=>['nullable','required_without:technician_material_id','integer','exists:technician_consumables,id'],'qty'=>['required','numeric','gt:0'],'notes'=>['nullable','string','max:1000']]);
abort_if(!empty($data['technician_material_id'])&&!empty($data['technician_consumable_id']),422,'Pilih satu material saja.');
$material=\Illuminate\Support\Facades\DB::transaction(function()use($data,$ticket,$request){
if(!empty($data['technician_material_id'])){$source=\App\Models\TechnicianMaterial::whereKey($data['technician_material_id'])->where('user_id',$request->user()->id)->where('status','assigned')->lockForUpdate()->firstOrFail();$qty=1;$source->update(['status'=>'used']);$name=$source->material_name;}
else{$source=\App\Models\TechnicianConsumable::whereKey($data['technician_consumable_id'])->where('user_id',$request->user()->id)->where('status','assigned')->lockForUpdate()->firstOrFail();$qty=(float)$data['qty'];abort_if((float)$source->qty_remaining<$qty,422,'Jumlah material yang dibawa tidak mencukupi.');$source->update(['qty_used'=>(float)$source->qty_used+$qty,'qty_remaining'=>(float)$source->qty_remaining-$qty]);$name=$source->material_name;}
\App\Models\MaterialMovement::create(['barcode_id'=>$source->barcode_id,'user_id'=>$request->user()->id,'from_user_id'=>$request->user()->id,'movement_type'=>'use','ticket_id'=>$ticket->id,'qty'=>$qty,'description'=>$data['notes']??null,'created_by'=>$request->user()->id,'created_at'=>now()]);
$warehouseBarcode=\App\Models\ItemBarcode::where('barcode',$source->barcode_id)->first();
if($warehouseBarcode)\App\Models\ItemMovement::create(['tenant_id'=>$ticket->tenant_id,'warehouse_item_id'=>$warehouseBarcode->warehouse_item_id,'item_barcode_id'=>$warehouseBarcode->id,'movement_type'=>'used_on_ticket','qty'=>$qty,'reference_type'=>'ticket','reference_id'=>$ticket->id,'from_user_id'=>$request->user()->id,'notes'=>$data['notes']??null,'created_by'=>$request->user()->id]);
return $ticket->materials()->create([...$data,'material_name'=>$name,'qty'=>$qty,'used_by'=>$request->user()->id,'created_at'=>now()]);
});
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Material Digunakan','notes'=>$material->material_name.' x '.$material->qty,'created_at'=>now()]);
return $this->created($material->load('user:id,name'),'Material ticket berhasil ditambahkan');
}
public function materialOptions(Request $request, Ticket $ticket)
{
$this->authorizeTenant($request,$ticket);$userId=$request->user()->id;
return $this->success(['serialized'=>\App\Models\TechnicianMaterial::where('user_id',$userId)->where('status','assigned')->get(['id','barcode_id','material_name','serial_number']),'consumables'=>\App\Models\TechnicianConsumable::where('user_id',$userId)->where('status','assigned')->where('qty_remaining','>',0)->get(['id','barcode_id','material_name','unit','qty_remaining'])],'Material yang dibawa berhasil diambil');
}
public function installationPorts(Request $request,Ticket $ticket,int $node,\App\Services\Customer\CustomerService $customers){$this->authorizeTenant($request,$ticket);return $this->success($customers->availableOdpPorts($node,$request->user(),$ticket->tenant_id,$ticket->customer_id),'Port ODP berhasil diambil');}
public function destroyMaterial(Request $request, Ticket $ticket, TicketMaterial $material)
{
$this->authorizeTenant($request,$ticket);abort_unless((int)$material->ticket_id===(int)$ticket->id,404);
abort_if(in_array($ticket->status,['resolved','closed'],true),422,'Material ticket yang sudah selesai tidak dapat diubah.');
\Illuminate\Support\Facades\DB::transaction(function()use($request,$ticket,$material){
if($material->technician_material_id){$source=\App\Models\TechnicianMaterial::lockForUpdate()->find($material->technician_material_id);if($source)$source->update(['status'=>'assigned']);}
else{$source=\App\Models\TechnicianConsumable::lockForUpdate()->find($material->technician_consumable_id);if($source)$source->update(['qty_used'=>max(0,(float)$source->qty_used-(float)$material->qty),'qty_remaining'=>(float)$source->qty_remaining+(float)$material->qty,'status'=>'assigned']);}
if(isset($source)&&$source){\App\Models\MaterialMovement::create(['barcode_id'=>$source->barcode_id,'user_id'=>$request->user()->id,'to_user_id'=>$request->user()->id,'movement_type'=>'return','ticket_id'=>$ticket->id,'qty'=>$material->qty,'description'=>'Pembatalan pemakaian material pada ticket','created_by'=>$request->user()->id,'created_at'=>now()]);$warehouseBarcode=\App\Models\ItemBarcode::where('barcode',$source->barcode_id)->first();if($warehouseBarcode)\App\Models\ItemMovement::create(['tenant_id'=>$ticket->tenant_id,'warehouse_item_id'=>$warehouseBarcode->warehouse_item_id,'item_barcode_id'=>$warehouseBarcode->id,'movement_type'=>'ticket_usage_cancelled','qty'=>$material->qty,'reference_type'=>'ticket','reference_id'=>$ticket->id,'to_user_id'=>$request->user()->id,'notes'=>'Material dikembalikan ke saldo teknisi','created_by'=>$request->user()->id]);}
$material->delete();
});
return $this->deleted('Material ticket berhasil dihapus dan dikembalikan ke daftar bawaan user');
}
private function authorizeTenant(Request $request, Ticket $ticket): void
{
abort_unless($request->user()->isMasterAdmin() || (int) $ticket->tenant_id === (int) $request->attributes->get('tenant_id'), 403);
}
}
@@ -17,6 +17,8 @@ use App\Http\Requests\Ticket\CloseTicketRequest;
use App\Http\Requests\Ticket\CancelTicketRequest;
use App\Http\Requests\Ticket\EscalateTicketRequest;
use App\Http\Requests\Ticket\StartTicketRequest;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class TicketWorkflowController extends ApiController
{
@@ -29,6 +31,7 @@ class TicketWorkflowController extends ApiController
*/
public function history(Ticket $ticket)
{
$this->authorizeTicket($ticket);
$data = $this->service->history($ticket);
return $this->success(
@@ -44,6 +47,7 @@ class TicketWorkflowController extends ApiController
AssignTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->assign(
$ticket,
$request->technicians,
@@ -64,6 +68,7 @@ class TicketWorkflowController extends ApiController
ReassignTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->reassign(
$ticket,
$request->technicians,
@@ -84,6 +89,7 @@ class TicketWorkflowController extends ApiController
ApproveTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->approve(
$ticket,
$request->notes,
@@ -103,6 +109,7 @@ class TicketWorkflowController extends ApiController
RejectTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->reject(
$ticket,
$request->reason,
@@ -122,6 +129,7 @@ class TicketWorkflowController extends ApiController
StartTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->start(
$ticket,
$request->validated(),
@@ -141,10 +149,12 @@ class TicketWorkflowController extends ApiController
PendingTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->pending(
$ticket,
$request->reason,
auth()->id() ?? 1
auth()->id() ?? 1,
$request->validated('stored_file_ids')
);
return $this->success(
@@ -158,6 +168,7 @@ class TicketWorkflowController extends ApiController
*/
public function resume(Ticket $ticket)
{
$this->authorizeTicket($ticket);
$ticket = $this->service->resume(
$ticket,
auth()->id() ?? 1
@@ -176,10 +187,12 @@ class TicketWorkflowController extends ApiController
ResolveTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->resolve(
$ticket,
$request->notes,
auth()->id() ?? 1
auth()->id() ?? 1,
$request->validated()
);
return $this->success(
@@ -195,6 +208,7 @@ class TicketWorkflowController extends ApiController
CloseTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->close(
$ticket,
$request->notes,
@@ -207,6 +221,42 @@ class TicketWorkflowController extends ApiController
);
}
public function verify(Request $request, Ticket $ticket)
{
$this->authorizeTicket($ticket);
abort_unless($ticket->status === 'resolved', 422, 'Hanya ticket selesai yang dapat diverifikasi.');
$data=$request->validate(['notes'=>['required','string','max:2000']]);
$ticket->update(['verified_by'=>$request->user()->id,'verified_at'=>now(),'verification_notes'=>$data['notes']]);
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Verified','notes'=>$data['notes'],'created_at'=>now()]);
return $this->updated(new TicketResource($ticket->fresh()),'Ticket berhasil diverifikasi');
}
public function installationStep(Request $request, Ticket $ticket, \App\Services\Customer\CustomerService $customers)
{
$this->authorizeTicket($ticket);
abort_unless($ticket->source === 'new_installation' && $ticket->customer_id,422,'Ticket bukan pemasangan baru.');
abort_unless(in_array($ticket->status,['progress','pending'],true),422,'Pekerjaan harus dimulai terlebih dahulu.');
$step=$request->validate(['step'=>['required',Rule::in(['documentation','service','activation'])]])['step'];
$expected=$ticket->installation_step ?: 'documentation'; abort_unless($step===$expected,422,"Tahap yang harus dikerjakan adalah {$expected}.");
$customer=$customers->find($ticket->customer_id);
if($step==='documentation'){
$data=$request->validate(['stored_file_ids'=>['required','array','min:1'],'stored_file_ids.*'=>['integer','exists:stored_files,id'],'topology_id'=>['required','integer','exists:topology_nodes,id'],'topology_port_number'=>['required','integer','min:1']]);
$images=collect($data['stored_file_ids'])->map(fn($id)=>['stored_file_id'=>$id,'category'=>'installation','is_cover'=>false])->all();
$customers->update($customer,['tenant_id'=>$customer->tenant_id,'topology_id'=>$data['topology_id'],'topology_port_number'=>$data['topology_port_number'],'images'=>$images],$request->user());
foreach($data['stored_file_ids'] as $id)\App\Models\TicketAttachment::firstOrCreate(['ticket_id'=>$ticket->id,'stored_file_id'=>$id,'stage'=>'installation_documentation'],['uploaded_by'=>$request->user()->id]);
$next='service';
}elseif($step==='service'){
$data=$request->validate(['package_profile_id'=>['required','integer','exists:nas_package_profiles,id'],'nas_mikrotik_id'=>['required','integer','exists:nas_mikrotiks,id'],'billing_profile_id'=>['required','integer','exists:billing_profiles,id']]);
$customers->update($customer,['tenant_id'=>$customer->tenant_id,...$data],$request->user()); $next='activation';
}else{
$data=$request->validate(['package_profile_id'=>['required','integer'],'nas_mikrotik_id'=>['required','integer'],'billing_profile_id'=>['required','integer'],'external_username'=>['required','string','max:255'],'external_password'=>['required','string','min:4'],'serial_number'=>['nullable','string','max:120'],'mac_address'=>['nullable','regex:/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/']]);
$customers->activate($customer,['activation_source'=>'ticket',...$data],$request->user()); $next='completed';
}
$ticket->update(['installation_step'=>$next]);
\App\Models\TicketLog::create(['ticket_id'=>$ticket->id,'user_id'=>$request->user()->id,'activity'=>'Tahap Pemasangan Selesai','notes'=>$step.' → '.$next,'created_at'=>now()]);
return $this->updated(new TicketResource($ticket->fresh()->load(['customer','attachments'])),'Tahap pemasangan berhasil disimpan');
}
/**
* Cancel
*/
@@ -214,6 +264,7 @@ class TicketWorkflowController extends ApiController
CancelTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->cancel(
$ticket,
$request->reason,
@@ -233,6 +284,7 @@ class TicketWorkflowController extends ApiController
EscalateTicketRequest $request,
Ticket $ticket
) {
$this->authorizeTicket($ticket);
$ticket = $this->service->escalate(
$ticket,
$request->reason,
@@ -244,4 +296,11 @@ class TicketWorkflowController extends ApiController
'Ticket berhasil dieskalasi'
);
}
}
private function authorizeTicket(Ticket $ticket): void
{
$user = auth()->user();
$tenantId = request()->attributes->get('tenant_id');
abort_unless($user?->isMasterAdmin() || (int) $ticket->tenant_id === (int) $tenantId, 403);
}
}