# UI Improvements Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add permission-gated CRUD actions, revise soft/hard delete strategy, add image lightbox modal, and add collapsible sidebar sections.

**Architecture:** Backend `/me` extended to return user assignments with flat permission list per SPBU. Frontend `usePermission(modul, aksi)` hook checks this data client-side. Image lightbox uses a Zustand store + React portal so any page can trigger it via `ClickableImage`. Sidebar uses React state + `localStorage` for collapsible sections.

**Tech Stack:** FastAPI + SQLAlchemy 2.x (backend), Next.js 16 App Router + Zustand + TanStack Query (frontend), Tabler/Bootstrap CSS.

**Spec:** `docs/superpowers/specs/2026-04-11-ui-improvements-design.md`

---

## File Map

### Backend — Modified
- `backend/app/schemas/auth.py` — add `UserAssignmentWithPermissions`, extend `UserResponse`
- `backend/app/repositories/user_repository.py` — add `get_with_permissions()`
- `backend/app/routers/auth.py` — update `/me` to build permission response
- `backend/app/services/spbu_service.py` — remove `is_dev_mode` from 7 delete functions
- `backend/app/services/user_service.py` — remove `is_dev_mode` from `delete_user`
- `backend/app/repositories/general_affairs_repository.py` — add `hard_delete_absensi`, `hard_delete_housekeeping`
- `backend/app/services/general_affairs_service.py` — add `delete_absensi`, `delete_housekeeping`
- `backend/app/routers/general_affairs.py` — add 2 DELETE endpoints

### Frontend — New Files
- `frontend/src/lib/hooks/usePermission.ts` — `usePermission(modul, aksi)` hook
- `frontend/src/components/ui/permission-guard.tsx` — `PermissionGuard` wrapper component
- `frontend/src/components/ui/image-lightbox.tsx` — `ImageLightbox`, `ClickableImage`, `useLightboxStore`

### Frontend — Modified
- `frontend/src/types/index.ts` — add `can_be_scheduled` + `permissions` to `UserSpbuAssignment`
- `frontend/src/lib/hooks/useGeneralAffairs.ts` — add `useDeleteAbsensi`, `useDeleteHousekeeping`; fix `useIsOperatorMode`
- `frontend/src/lib/api/general-affairs.ts` — add `deleteAbsensi`, `deleteHousekeeping`
- `frontend/src/app/(dashboard)/layout.tsx` — mount `<ImageLightbox />`
- `frontend/src/app/(dashboard)/general-affairs/absensi/absensi-client.tsx` — permission gates + delete + ClickableImage
- `frontend/src/app/(dashboard)/general-affairs/housekeeping/housekeeping-client.tsx` — permission gates + delete + ClickableImage
- `frontend/src/app/(dashboard)/general-affairs/jadwal/jadwal-client.tsx` — permission gates on create/delete
- `frontend/src/components/ui/sidebar.tsx` — collapsible sections with localStorage
- `CLAUDE.md` — update rule 18

---

## Task 1: Backend — Extend `/me` schema and repository

**Files:**
- Modify: `backend/app/schemas/auth.py`
- Modify: `backend/app/repositories/user_repository.py`
- Modify: `backend/app/routers/auth.py`

- [ ] **Step 1.1: Add `UserAssignmentWithPermissions` schema and extend `UserResponse`**

Replace the entire `backend/app/schemas/auth.py`:

```python
from pydantic import BaseModel, ConfigDict


class LoginRequest(BaseModel):
    identifier: str  # accepts email or username
    password: str


class TokenResponse(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"


class RefreshRequest(BaseModel):
    refresh_token: str | None = None


class UserAssignmentWithPermissions(BaseModel):
    spbu_id: int
    spbu_name: str
    role_id: int
    role_name: str
    can_be_scheduled: bool
    permissions: list[str]  # e.g. ["absensi:view", "absensi:delete"]


class UserResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str
    email: str
    is_superadmin: bool
    is_active: bool
    assignments: list[UserAssignmentWithPermissions] = []
```

- [ ] **Step 1.2: Add `get_with_permissions` to user repository**

Add this function to `backend/app/repositories/user_repository.py` after the `get_by_id` function (after line 38):

```python
async def get_with_permissions(db: AsyncSession, user_id: int) -> User | None:
    """Fetch a user with all SPBU assignments, roles, and role permissions loaded."""
    from app.models.role import Role, RolePermission  # noqa: F401 (loaded via relationship)
    result = await db.execute(
        select(User)
        .where(User.id == user_id, User.deleted_at.is_(None))
        .options(
            selectinload(User.assignments).options(
                selectinload(UserSpbuAssignment.role).selectinload(Role.permissions),
                selectinload(UserSpbuAssignment.spbu),
            )
        )
    )
    return result.scalar_one_or_none()
```

This requires adding `from app.models.spbu import Spbu` to the imports at the top of the file (it's already imported via the role model's forward ref, but make it explicit):

In the imports at top of `user_repository.py`, add:
```python
from app.models.role import Role, RolePermission, UserSpbuAssignment
```

(Replace the existing `from app.models.role import Role, UserSpbuAssignment` line — just add `RolePermission` to it.)

- [ ] **Step 1.3: Update `/me` router handler to build permission response**

Replace the `/me` handler in `backend/app/routers/auth.py`. First update the imports:

```python
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
from app.core.database import get_db
from app.core.security import verify_token
from app.dependencies import get_current_user
from app.models.user import User
from app.repositories import user_repository
from app.schemas.auth import LoginRequest, TokenResponse, UserAssignmentWithPermissions, UserResponse
from app.services import auth_service
```

Then replace the `/me` endpoint (lines 96-98):

```python
@router.get("/me", response_model=UserResponse)
async def me(
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
) -> UserResponse:
    user = await user_repository.get_with_permissions(db, current_user.id)
    assignments = []
    for a in (user.assignments if user else []):
        role = a.role
        spbu = a.spbu
        assignments.append(UserAssignmentWithPermissions(
            spbu_id=a.spbu_id,
            spbu_name=spbu.name if spbu else "",
            role_id=a.role_id,
            role_name=role.nama if role else "",
            can_be_scheduled=role.can_be_scheduled if role else False,
            permissions=[
                f"{p.modul.value}:{p.aksi.value}"
                for p in (role.permissions if role else [])
            ],
        ))
    return UserResponse(
        id=current_user.id,
        name=current_user.name,
        email=current_user.email,
        is_superadmin=current_user.is_superadmin,
        is_active=current_user.is_active,
        assignments=assignments,
    )
```

- [ ] **Step 1.4: Test manually**

Start the backend and call the endpoint:
```bash
cd backend
# GET /api/v1/auth/me (with valid cookie)
# Expected response:
# {
#   "id": 1, "name": "...", "is_superadmin": true,
#   "assignments": [
#     { "spbu_id": 1, "spbu_name": "SPBU ...", "role_id": 2,
#       "can_be_scheduled": false,
#       "permissions": ["absensi:view", "absensi:create", ...] }
#   ]
# }
```

Superadmin users will have `assignments: []` but is_superadmin bypass handles them.

- [ ] **Step 1.5: Commit**

```bash
git add backend/app/schemas/auth.py backend/app/repositories/user_repository.py backend/app/routers/auth.py
git commit -m "feat: extend /me to return SPBU assignments with permissions"
```

---

## Task 2: Backend — Remove `is_dev_mode` from delete services

**Files:**
- Modify: `backend/app/services/spbu_service.py`
- Modify: `backend/app/services/user_service.py`

- [ ] **Step 2.1: Update `spbu_service.py` — 7 delete functions**

In `backend/app/services/spbu_service.py`, find all occurrences of this pattern and remove the `is_dev_mode` call, replacing with `hard_delete=False`:

**`delete_spbu` (around line 66):** Replace:
```python
hard_delete = await system_repository.is_dev_mode(db)
await spbu_repository.delete_spbu(db, spbu, hard_delete=hard_delete)
```
With:
```python
await spbu_repository.delete_spbu(db, spbu, hard_delete=False)
```

**`delete_shift` (around line 107):** Replace:
```python
hard_delete = await system_repository.is_dev_mode(db)
await spbu_repository.delete_shift(db, shift, hard_delete=hard_delete)
```
With:
```python
await spbu_repository.delete_shift(db, shift, hard_delete=False)
```

**`delete_island` (around line 140):** Replace:
```python
hard_delete = await system_repository.is_dev_mode(db)
await spbu_repository.delete_island(db, island, hard_delete=hard_delete)
```
With:
```python
await spbu_repository.delete_island(db, island, hard_delete=False)
```

**`delete_nozzle` (around line 170):** Replace:
```python
hard_delete = await system_repository.is_dev_mode(db)
await spbu_repository.delete_nozzle(db, nozzle, hard_delete=hard_delete)
```
With:
```python
await spbu_repository.delete_nozzle(db, nozzle, hard_delete=False)
```

**`delete_tangki` (around line 203):** Replace:
```python
hard_delete = await system_repository.is_dev_mode(db)
await spbu_repository.delete_tangki(db, tangki, hard_delete=hard_delete)
```
With:
```python
await spbu_repository.delete_tangki(db, tangki, hard_delete=False)
```

**`delete_tenant` (around line 247):** Replace:
```python
hard_delete = await system_repository.is_dev_mode(db)
await spbu_repository.delete_tenant(db, tenant, hard_delete=hard_delete)
```
With:
```python
await spbu_repository.delete_tenant(db, tenant, hard_delete=False)
```

**`delete_kontrak` (around line 281):** Replace:
```python
hard_delete = await system_repository.is_dev_mode(db)
await spbu_repository.delete_kontrak(db, kontrak, hard_delete=hard_delete)
```
With:
```python
await spbu_repository.delete_kontrak(db, kontrak, hard_delete=False)
```

After all 7 changes, if `system_repository` is no longer imported anywhere in `spbu_service.py`, remove its import line.

- [ ] **Step 2.2: Update `user_service.py` — `delete_user`**

In `backend/app/services/user_service.py`, find the `delete_user` function (around line 91). Replace:
```python
hard = await system_repository.is_dev_mode(db)
await user_repository.delete_user(db, user, hard=hard)
```
With:
```python
await user_repository.delete_user(db, user, hard=False)
```

If `system_repository` is no longer used elsewhere in `user_service.py`, remove its import.

- [ ] **Step 2.3: Commit**

```bash
git add backend/app/services/spbu_service.py backend/app/services/user_service.py
git commit -m "refactor: master data always soft-deletes, remove environment_mode delete logic"
```

---

## Task 3: Backend — Delete endpoints for absensi and housekeeping

**Files:**
- Modify: `backend/app/repositories/general_affairs_repository.py`
- Modify: `backend/app/services/general_affairs_service.py`
- Modify: `backend/app/routers/general_affairs.py`

- [ ] **Step 3.1: Add hard delete functions to repository**

Add these two functions to the end of `backend/app/repositories/general_affairs_repository.py`:

```python
async def hard_delete_absensi(
    db: AsyncSession, absensi_id: int, spbu_id: int
) -> Absensi | None:
    """Hard delete absensi. Returns the deleted record, or None if not found."""
    absensi = await get_absensi_by_id(db, absensi_id)
    if not absensi or absensi.spbu_id != spbu_id:
        return None
    await db.delete(absensi)
    await db.commit()
    return absensi


async def hard_delete_housekeeping(
    db: AsyncSession, hk_id: int, spbu_id: int
) -> Housekeeping | None:
    """Hard delete housekeeping and its cascaded items/fotos."""
    hk = await get_housekeeping_by_id(db, hk_id)
    if not hk or hk.spbu_id != spbu_id:
        return None
    await db.delete(hk)
    await db.commit()
    return hk
```

Note: `housekeeping_item` and `housekeeping_foto` are cascade-deleted automatically since the models use `cascade="all, delete-orphan"`.

- [ ] **Step 3.2: Add delete service functions**

Add these two functions to `backend/app/services/general_affairs_service.py`:

```python
async def delete_absensi(db: AsyncSession, spbu_id: int, absensi_id: int) -> None:
    absensi = await repo.get_absensi_by_id(db, absensi_id)
    if not absensi or absensi.spbu_id != spbu_id:
        raise ValueError("Absensi tidak ditemukan")
    if absensi.status == "approved":
        raise ValueError("Tidak dapat menghapus absensi yang sudah disetujui")
    await repo.hard_delete_absensi(db, absensi_id, spbu_id)


async def delete_housekeeping(db: AsyncSession, spbu_id: int, hk_id: int) -> None:
    hk = await repo.get_housekeeping_by_id(db, hk_id)
    if not hk or hk.spbu_id != spbu_id:
        raise ValueError("Housekeeping tidak ditemukan")
    if hk.status == "approved":
        raise ValueError("Tidak dapat menghapus housekeeping yang sudah disetujui")
    await repo.hard_delete_housekeeping(db, hk_id, spbu_id)
```

- [ ] **Step 3.3: Add DELETE router endpoints**

Add these two endpoints to `backend/app/routers/general_affairs.py`, after the `approve_absensi` endpoint and after the `approve_housekeeping` endpoint respectively:

After `approve_absensi` (after line ~189):
```python
@router.delete("/spbus/{spbu_id}/absensi/{absensi_id}", status_code=status.HTTP_200_OK)
async def delete_absensi(
    spbu_id: int,
    absensi_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> dict:
    try:
        await general_affairs_service.delete_absensi(db, spbu_id, absensi_id)
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
    return {"message": "Absensi berhasil dihapus"}
```

After `approve_housekeeping` (at end of file):
```python
@router.delete("/spbus/{spbu_id}/housekeeping/{hk_id}", status_code=status.HTTP_200_OK)
async def delete_housekeeping(
    spbu_id: int,
    hk_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> dict:
    try:
        await general_affairs_service.delete_housekeeping(db, spbu_id, hk_id)
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
    return {"message": "Housekeeping berhasil dihapus"}
```

- [ ] **Step 3.4: Test endpoints manually**

```bash
# Test delete pending absensi — should succeed
curl -X DELETE http://localhost:8006/api/v1/spbus/1/absensi/5 \
  -H "Cookie: access_token=..."
# Expected: {"message": "Absensi berhasil dihapus"}

# Test delete approved absensi — should return 400
# Expected: {"detail": "Tidak dapat menghapus absensi yang sudah disetujui"}

# Test delete nonexistent — should return 400
# Expected: {"detail": "Absensi tidak ditemukan"}
```

- [ ] **Step 3.5: Commit**

```bash
git add backend/app/repositories/general_affairs_repository.py \
        backend/app/services/general_affairs_service.py \
        backend/app/routers/general_affairs.py
git commit -m "feat: add DELETE endpoints for absensi and housekeeping (pending-only)"
```

---

## Task 4: Update CLAUDE.md

**Files:**
- Modify: `CLAUDE.md`

- [ ] **Step 4.1: Update rule 18**

In `CLAUDE.md`, find rule 18 (currently: `**Soft delete** default (production). Hard delete hanya jika environment_mode = 'development'. Pengecualian: jadwal selalu hard delete`).

Replace it with:

```
18. **Delete strategy**: Master data (users, roles, master_spbu, shifts, islands, nozzles, tangki, products, tenants, contracts, expense_kategori) → **selalu soft delete**, tanpa peduli environment. Operational/transactional data (absensi, housekeeping, jadwal, laporan_shift, expenses, penyetoran, dll) → **hard delete** dengan orphan check (IntegrityError → 400 dengan pesan deskriptif). `environment_mode` config key dipertahankan sebagai general dev flag untuk fitur dev-only di masa depan (seeding, debug panel) — bukan lagi untuk kontrol delete behavior.
```

Also update `backend/CLAUDE.md` rule under "Soft delete": change `Hard delete hanya jika environment_mode = 'development'.` to `Operational data always hard deletes with orphan check.`

- [ ] **Step 4.2: Commit**

```bash
git add CLAUDE.md backend/CLAUDE.md
git commit -m "docs: update delete strategy — master data always soft, operational always hard"
```

---

## Task 5: Frontend — Permission types + hook + guard

**Files:**
- Modify: `frontend/src/types/index.ts`
- Create: `frontend/src/lib/hooks/usePermission.ts`
- Create: `frontend/src/components/ui/permission-guard.tsx`
- Modify: `frontend/src/lib/hooks/useGeneralAffairs.ts`

- [ ] **Step 5.1: Extend `UserSpbuAssignment` type**

In `frontend/src/types/index.ts`, find the `UserSpbuAssignment` interface (lines 11–19) and add two optional fields:

```ts
export interface UserSpbuAssignment {
  id?: number
  user_id?: number
  spbu_id: number
  role_id: number
  spbu_name?: string
  role_name?: string
  role_can_be_scheduled?: boolean  // from users list API
  can_be_scheduled?: boolean       // from /me API (new)
  permissions?: string[]           // from /me API (new) e.g. ["absensi:view", "absensi:delete"]
}
```

- [ ] **Step 5.2: Create `usePermission` hook**

Create `frontend/src/lib/hooks/usePermission.ts`:

```ts
import { useMe } from '@/lib/hooks/useAuth'
import { useSpbuStore } from '@/stores/spbu-store'
import type { ModulType, AksiType } from '@/types'

/**
 * Returns true if the current user has the given permission for the active SPBU.
 * Superadmin always returns true. Returns false while auth data is loading.
 */
export function usePermission(modul: ModulType, aksi: AksiType): boolean {
  const { data: me } = useMe()
  const { activeSPBU } = useSpbuStore()

  if (!me) return false
  if (me.is_superadmin) return true

  const assignment = me.assignments?.find((a) => a.spbu_id === activeSPBU?.id)
  return assignment?.permissions?.includes(`${modul}:${aksi}`) ?? false
}
```

- [ ] **Step 5.3: Create `PermissionGuard` component**

Create `frontend/src/components/ui/permission-guard.tsx`:

```tsx
'use client'

import { usePermission } from '@/lib/hooks/usePermission'
import type { ModulType, AksiType } from '@/types'

/**
 * Renders children only if the current user has the given permission.
 * Renders nothing otherwise. Superadmin always renders.
 *
 * Usage: <PermissionGuard modul="absensi" aksi="delete">
 *          <button>Hapus</button>
 *        </PermissionGuard>
 */
export function PermissionGuard({
  modul,
  aksi,
  children,
}: {
  modul: ModulType
  aksi: AksiType
  children: React.ReactNode
}) {
  const can = usePermission(modul, aksi)
  return can ? <>{children}</> : null
}
```

- [ ] **Step 5.4: Fix `useIsOperatorMode` to use the new `can_be_scheduled` field**

In `frontend/src/lib/hooks/useGeneralAffairs.ts`, replace the `useIsOperatorMode` function body:

```ts
/** Returns true if the current user's role at the active SPBU has can_be_scheduled=true (operator/OB mode). */
export function useIsOperatorMode(): boolean {
  const { data: me } = useMe()
  const { activeSPBU } = useSpbuStore()
  if (!me || me.is_superadmin || !activeSPBU) return false
  const assignment = me.assignments?.find((a) => a.spbu_id === activeSPBU.id)
  // can_be_scheduled comes from /me (new); role_can_be_scheduled from users list API (legacy)
  return assignment?.can_be_scheduled ?? assignment?.role_can_be_scheduled ?? false
}
```

- [ ] **Step 5.5: Commit**

```bash
git add frontend/src/types/index.ts \
        frontend/src/lib/hooks/usePermission.ts \
        frontend/src/components/ui/permission-guard.tsx \
        frontend/src/lib/hooks/useGeneralAffairs.ts
git commit -m "feat: add usePermission hook and PermissionGuard component"
```

---

## Task 6: Frontend — API methods + mutation hooks for delete

**Files:**
- Modify: `frontend/src/lib/api/general-affairs.ts`
- Modify: `frontend/src/lib/hooks/useGeneralAffairs.ts`

- [ ] **Step 6.1: Add delete API methods**

In `frontend/src/lib/api/general-affairs.ts`, add these two entries to the `gaApi` object (after `approveAbsensi` and after `approveHousekeeping`):

After `approveAbsensi`:
```ts
deleteAbsensi: (spbuId: number, absensiId: number): Promise<{ message: string }> =>
  apiClient.delete(`/spbus/${spbuId}/absensi/${absensiId}`).then((r) => r.data),
```

After `approveHousekeeping`:
```ts
deleteHousekeeping: (spbuId: number, hkId: number): Promise<{ message: string }> =>
  apiClient.delete(`/spbus/${spbuId}/housekeeping/${hkId}`).then((r) => r.data),
```

- [ ] **Step 6.2: Add mutation hooks**

In `frontend/src/lib/hooks/useGeneralAffairs.ts`, add after `useApproveAbsensi`:

```ts
export function useDeleteAbsensi(spbuId: number | undefined) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (absensiId: number) => gaApi.deleteAbsensi(spbuId!, absensiId),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['absensi', spbuId] })
      qc.invalidateQueries({ queryKey: ['absensi-slot', spbuId] })
    },
  })
}
```

And add after `useApproveHousekeeping`:

```ts
export function useDeleteHousekeeping(spbuId: number | undefined) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (hkId: number) => gaApi.deleteHousekeeping(spbuId!, hkId),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['housekeeping', spbuId] })
      qc.invalidateQueries({ queryKey: ['housekeeping-slot', spbuId] })
    },
  })
}
```

- [ ] **Step 6.3: Commit**

```bash
git add frontend/src/lib/api/general-affairs.ts \
        frontend/src/lib/hooks/useGeneralAffairs.ts
git commit -m "feat: add deleteAbsensi and deleteHousekeeping API + hooks"
```

---

## Task 7: Frontend — Image lightbox component

**Files:**
- Create: `frontend/src/components/ui/image-lightbox.tsx`
- Modify: `frontend/src/app/(dashboard)/layout.tsx`

- [ ] **Step 7.1: Create `image-lightbox.tsx`**

Create `frontend/src/components/ui/image-lightbox.tsx`:

```tsx
'use client'

import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { create } from 'zustand'

// ── Lightbox store ─────────────────────────────────────────────────────────

interface LightboxState {
  src: string | null
  alt: string
  open: (src: string, alt?: string) => void
  close: () => void
}

export const useLightboxStore = create<LightboxState>((set) => ({
  src: null,
  alt: '',
  open: (src, alt = '') => set({ src, alt }),
  close: () => set({ src: null, alt: '' }),
}))

// ── ImageLightbox modal ────────────────────────────────────────────────────

/**
 * Mount once in (dashboard)/layout.tsx.
 * Triggered globally via useLightboxStore.open(src, alt).
 */
export function ImageLightbox() {
  const { src, alt, close } = useLightboxStore()

  useEffect(() => {
    if (!src) return
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'Escape') close()
    }
    document.addEventListener('keydown', handler)
    return () => document.removeEventListener('keydown', handler)
  }, [src, close])

  if (!src || typeof document === 'undefined') return null

  return createPortal(
    <>
      <div
        className="modal-backdrop fade show"
        style={{ zIndex: 1060 }}
        onClick={close}
      />
      <div
        className="modal modal-blur fade show d-block"
        tabIndex={-1}
        role="dialog"
        style={{ zIndex: 1065 }}
        onClick={close}
      >
        <div
          className="modal-dialog modal-dialog-centered"
          style={{ maxWidth: '90vw', width: 'fit-content' }}
          onClick={(e) => e.stopPropagation()}
        >
          <div className="modal-content" style={{ background: '#000', border: 'none' }}>
            <div className="modal-header border-0 pb-0" style={{ background: 'transparent' }}>
              <button
                type="button"
                className="btn-close btn-close-white ms-auto"
                onClick={close}
              />
            </div>
            <div className="modal-body p-2 pt-0 text-center">
              <img
                src={src}
                alt={alt}
                style={{
                  maxHeight: '85vh',
                  maxWidth: '85vw',
                  objectFit: 'contain',
                  borderRadius: 4,
                }}
              />
            </div>
          </div>
        </div>
      </div>
    </>,
    document.body
  )
}

// ── ClickableImage ─────────────────────────────────────────────────────────

/**
 * Drop-in replacement for <img> thumbnails that should open in a lightbox.
 * Accepts all standard <img> props.
 *
 * Usage: <ClickableImage src={url} alt="foto" style={{ width: 80, height: 80 }} />
 */
export function ClickableImage({
  src,
  alt = '',
  ...props
}: React.ImgHTMLAttributes<HTMLImageElement>) {
  const open = useLightboxStore((s) => s.open)

  return (
    <img
      src={src}
      alt={alt}
      {...props}
      style={{ cursor: 'pointer', ...props.style }}
      onClick={(e) => {
        e.preventDefault()
        e.stopPropagation()
        if (src) open(src, alt)
      }}
    />
  )
}
```

- [ ] **Step 7.2: Mount `ImageLightbox` in dashboard layout**

In `frontend/src/app/(dashboard)/layout.tsx`, add the import and mount the component:

```tsx
import { Sidebar } from '@/components/ui/sidebar'
import { Topbar } from '@/components/ui/topbar'
import { MaintenancePoller } from '@/components/ui/maintenance-poller'
import { ChatWidget } from '@/components/ui/chat-widget'
import { ImageLightbox } from '@/components/ui/image-lightbox'

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="wrapper">
      <MaintenancePoller />
      <ImageLightbox />
      <Sidebar />
      <div className="page-wrapper">
        <Topbar />
        <div className="page-body">
          <div className="container-xl">{children}</div>
        </div>
        <footer className="footer footer-transparent d-print-none">
          <div className="container-xl">
            <div className="row text-center align-items-center">
              <div className="col text-muted small">
                SPBU Manager v1.0 &nbsp;·&nbsp;{' '}
                <span>⛽ SPBUManager</span>
              </div>
            </div>
          </div>
        </footer>
      </div>
      <ChatWidget />
    </div>
  )
}
```

- [ ] **Step 7.3: Commit**

```bash
git add frontend/src/components/ui/image-lightbox.tsx \
        frontend/src/app/(dashboard)/layout.tsx
git commit -m "feat: add ImageLightbox + ClickableImage component"
```

---

## Task 8: Frontend — Update absensi page

**Files:**
- Modify: `frontend/src/app/(dashboard)/general-affairs/absensi/absensi-client.tsx`

- [ ] **Step 8.1: Rewrite `absensi-client.tsx`**

Replace the entire content of `frontend/src/app/(dashboard)/general-affairs/absensi/absensi-client.tsx`:

```tsx
'use client'

import { useState, useRef, useEffect } from 'react'
import {
  useAbsensi,
  useAbsensiSlot,
  useUploadAbsensi,
  useApproveAbsensi,
  useDeleteAbsensi,
  useIsOperatorMode,
} from '@/lib/hooks/useGeneralAffairs'
import { usePermission } from '@/lib/hooks/usePermission'
import { useShifts } from '@/lib/hooks/useSPBUs'
import { useSpbuStore } from '@/stores/spbu-store'
import { ClickableImage } from '@/components/ui/image-lightbox'
import { isMobileDevice } from '@/lib/utils/device'
import { getActiveShift } from '@/lib/utils/shift'
import type { Absensi } from '@/types'

function toISO(d: Date): string {
  return d.toISOString().split('T')[0]
}

function formatDate(dateStr: string): string {
  return new Date(dateStr + 'T00:00:00').toLocaleDateString('id-ID', {
    day: 'numeric',
    month: 'short',
    year: 'numeric',
  })
}

async function readExifTimestamp(file: File): Promise<string | undefined> {
  try {
    const exifr = await import('exifr')
    const tags = await exifr.default.parse(file, ['DateTimeOriginal'])
    const dt: Date | undefined = tags?.DateTimeOriginal
    if (!dt) return undefined
    return dt.toISOString()
  } catch {
    return undefined
  }
}

// ── Upload / Replace photo for a specific slot ────────────────────────────

interface UploadPanelProps {
  spbuId: number
  shiftId: number
  tanggal: string
  shiftNama?: string
  existingUrl?: string
}

function UploadPanel({ spbuId, shiftId, tanggal, shiftNama, existingUrl }: UploadPanelProps) {
  const upload = useUploadAbsensi(spbuId)
  const [preview, setPreview] = useState<string | null>(existingUrl ?? null)
  const [file, setFile] = useState<File | null>(null)
  const [exifLabel, setExifLabel] = useState<string | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [done, setDone] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)

  const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const f = e.target.files?.[0]
    if (!f) return
    setFile(f)
    setPreview(URL.createObjectURL(f))
    setDone(false)
    const exif = await readExifTimestamp(f)
    if (exif) {
      const dt = new Date(exif)
      setExifLabel(
        dt.toLocaleString('id-ID', {
          day: 'numeric', month: 'short', year: 'numeric',
          hour: '2-digit', minute: '2-digit',
        })
      )
    } else {
      setExifLabel(null)
    }
  }

  const handleSubmit = async () => {
    if (!file) return
    setError(null)
    try {
      const exif = await readExifTimestamp(file)
      await upload.mutateAsync({ shiftId, tanggal, foto: file, fotoEksifWaktu: exif })
      setFile(null)
      setDone(true)
    } catch {
      setError('Upload gagal. Coba lagi.')
    }
  }

  if (done) {
    return (
      <div className="alert alert-success py-2 small mb-0">
        <i className="ti ti-check me-1"></i>Foto berhasil diupload.
      </div>
    )
  }

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        accept="image/*"
        capture={isMobileDevice() ? 'environment' : undefined}
        className="d-none"
        onChange={handleFile}
      />
      {error && <div className="alert alert-danger py-2 small mb-2">{error}</div>}
      {preview ? (
        <div className="mb-2">
          <ClickableImage
            src={preview}
            alt="preview"
            className="img-fluid rounded mb-2"
            style={{ maxHeight: 200, objectFit: 'cover', width: '100%' }}
          />
          {exifLabel && (
            <div className="text-muted small">
              <i className="ti ti-clock me-1"></i>Foto diambil: <strong>{exifLabel}</strong>
            </div>
          )}
        </div>
      ) : null}
      <div className="d-flex gap-2">
        <button className="btn btn-outline-secondary" onClick={() => inputRef.current?.click()}>
          <i className="ti ti-camera me-1"></i>
          {preview ? 'Ganti Foto' : 'Ambil Foto'}
        </button>
        {file && (
          <button
            className="btn btn-primary"
            disabled={upload.isPending}
            onClick={handleSubmit}
          >
            {upload.isPending && <span className="spinner-border spinner-border-sm me-1"></span>}
            Upload
          </button>
        )}
      </div>
    </div>
  )
}

// ── Existing absensi card display ─────────────────────────────────────────

function AbsensiCard({
  absensi,
  spbuId,
  canApprove,
  canDelete,
  onApprove,
  onDelete,
}: {
  absensi: Absensi
  spbuId: number
  canApprove: boolean
  canDelete: boolean
  onApprove: (id: number) => void
  onDelete: (id: number) => void
}) {
  const [replacing, setReplacing] = useState(false)
  const [confirmDelete, setConfirmDelete] = useState(false)

  let exifLabel: string | null = null
  if (absensi.foto_eksif_waktu) {
    try {
      const dt = new Date(absensi.foto_eksif_waktu)
      exifLabel = dt.toLocaleString('id-ID', {
        day: 'numeric', month: 'short', year: 'numeric',
        hour: '2-digit', minute: '2-digit',
      })
    } catch {
      exifLabel = absensi.foto_eksif_waktu
    }
  }

  return (
    <div className="card card-sm">
      <div className="card-body">
        <div className="d-flex align-items-start gap-3">
          {absensi.foto_url && (
            <div className="flex-shrink-0">
              <ClickableImage
                src={absensi.foto_url}
                alt="foto absensi"
                style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: 6 }}
              />
            </div>
          )}
          <div className="flex-grow-1">
            <div className="d-flex align-items-center gap-2 mb-1">
              <span className={`badge ${absensi.status === 'approved' ? 'bg-success' : 'bg-warning text-dark'}`}>
                {absensi.status === 'approved' ? 'Approved' : 'Pending'}
              </span>
              {absensi.uploaded_by_nama && (
                <span className="text-muted small">diupload oleh {absensi.uploaded_by_nama}</span>
              )}
            </div>
            {exifLabel && (
              <div className="text-muted small mb-1">
                <i className="ti ti-clock me-1"></i>Foto diambil: <strong>{exifLabel}</strong>
              </div>
            )}
            {absensi.reviewed_by_nama && (
              <div className="text-muted small">Disetujui oleh {absensi.reviewed_by_nama}</div>
            )}
            <div className="d-flex gap-2 mt-2 flex-wrap">
              <button
                className="btn btn-sm btn-outline-secondary"
                onClick={() => setReplacing((v) => !v)}
              >
                <i className="ti ti-refresh me-1"></i>Ganti Foto
              </button>
              {canApprove && absensi.status === 'pending' && (
                <button
                  className="btn btn-sm btn-success"
                  onClick={() => onApprove(absensi.id)}
                >
                  <i className="ti ti-check me-1"></i>Setujui
                </button>
              )}
              {canDelete && absensi.status === 'pending' && !confirmDelete && (
                <button
                  className="btn btn-sm btn-ghost-danger"
                  onClick={() => setConfirmDelete(true)}
                >
                  <i className="ti ti-trash me-1"></i>Hapus
                </button>
              )}
              {canDelete && absensi.status === 'pending' && confirmDelete && (
                <>
                  <button
                    className="btn btn-sm btn-danger"
                    onClick={() => { onDelete(absensi.id); setConfirmDelete(false) }}
                  >
                    Ya, Hapus
                  </button>
                  <button
                    className="btn btn-sm btn-ghost-secondary"
                    onClick={() => setConfirmDelete(false)}
                  >
                    Batal
                  </button>
                </>
              )}
            </div>
          </div>
        </div>
        {replacing && (
          <div className="mt-3">
            <UploadPanel
              spbuId={spbuId}
              shiftId={absensi.shift_id}
              tanggal={absensi.tanggal}
            />
          </div>
        )}
      </div>
    </div>
  )
}

// ── Pending approvals list ────────────────────────────────────────────────

function PendingApprovals({
  spbuId,
  start,
  end,
  canApprove,
  canDelete,
}: {
  spbuId: number
  start: string
  end: string
  canApprove: boolean
  canDelete: boolean
}) {
  const { data: list = [] } = useAbsensi(spbuId, start, end)
  const approve = useApproveAbsensi(spbuId)
  const deleteAbsensi = useDeleteAbsensi(spbuId)
  const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null)
  const pending = list.filter((a) => a.status === 'pending' && a.foto_url)

  if (pending.length === 0) return null

  return (
    <div className="card mt-3">
      <div className="card-header">
        <h3 className="card-title">Menunggu Persetujuan</h3>
        <span className="badge bg-warning text-dark ms-2">{pending.length}</span>
      </div>
      <div className="table-responsive">
        <table className="table table-vcenter card-table">
          <thead>
            <tr>
              <th>Tanggal</th>
              <th>Shift</th>
              <th>Foto</th>
              <th>Waktu Foto</th>
              <th>Diupload Oleh</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            {pending.map((a) => {
              let exifLabel: string | null = null
              if (a.foto_eksif_waktu) {
                try {
                  exifLabel = new Date(a.foto_eksif_waktu).toLocaleString('id-ID', {
                    day: 'numeric', month: 'short', year: 'numeric',
                    hour: '2-digit', minute: '2-digit',
                  })
                } catch {
                  exifLabel = a.foto_eksif_waktu
                }
              }
              return (
                <tr key={a.id}>
                  <td>{formatDate(a.tanggal)}</td>
                  <td>{a.shift_nama ?? `#${a.shift_id}`}</td>
                  <td>
                    <ClickableImage
                      src={a.foto_url}
                      alt="foto absensi"
                      style={{ height: 48, width: 64, objectFit: 'cover', borderRadius: 4 }}
                    />
                  </td>
                  <td className="text-muted small">{exifLabel ?? '—'}</td>
                  <td className="text-muted small">{a.uploaded_by_nama ?? '—'}</td>
                  <td className="text-end">
                    <div className="d-flex gap-2 justify-content-end">
                      {canApprove && (
                        <button
                          className="btn btn-sm btn-success"
                          disabled={approve.isPending}
                          onClick={() => approve.mutate(a.id)}
                        >
                          <i className="ti ti-check me-1"></i>Setujui
                        </button>
                      )}
                      {canDelete && confirmDeleteId !== a.id && (
                        <button
                          className="btn btn-sm btn-ghost-danger"
                          onClick={() => setConfirmDeleteId(a.id)}
                        >
                          <i className="ti ti-trash"></i>
                        </button>
                      )}
                      {canDelete && confirmDeleteId === a.id && (
                        <>
                          <button
                            className="btn btn-sm btn-danger"
                            disabled={deleteAbsensi.isPending}
                            onClick={() => { deleteAbsensi.mutate(a.id); setConfirmDeleteId(null) }}
                          >
                            Ya, Hapus
                          </button>
                          <button
                            className="btn btn-sm btn-ghost-secondary"
                            onClick={() => setConfirmDeleteId(null)}
                          >
                            Batal
                          </button>
                        </>
                      )}
                    </div>
                  </td>
                </tr>
              )
            })}
          </tbody>
        </table>
      </div>
    </div>
  )
}

// ── Main page ─────────────────────────────────────────────────────────────

export function AbsensiClient() {
  const { activeSPBU } = useSpbuStore()
  const spbuId = activeSPBU?.id
  const isOperator = useIsOperatorMode()
  const canApprove = usePermission('absensi', 'approve')
  const canDelete = usePermission('absensi', 'delete')

  const today = toISO(new Date())
  const [tanggal, setTanggal] = useState(today)
  const [selectedShiftId, setSelectedShiftId] = useState<number | null>(null)

  const monthStart = tanggal.slice(0, 7) + '-01'
  const monthEnd = (() => {
    const [y, m] = tanggal.split('-').map(Number)
    return toISO(new Date(y, m, 0))
  })()

  const { data: shifts = [], isLoading: shiftsLoading } = useShifts(spbuId ?? null)
  const { data: slot, isLoading: slotLoading } = useAbsensiSlot(spbuId, tanggal, selectedShiftId ?? undefined)
  const approve = useApproveAbsensi(spbuId)
  const deleteAbsensi = useDeleteAbsensi(spbuId)

  useEffect(() => {
    if (shifts.length === 0) return
    if (isOperator) {
      const active = getActiveShift(shifts)
      setSelectedShiftId(active?.id ?? shifts[0].id)
    } else if (!selectedShiftId) {
      setSelectedShiftId(shifts[0].id)
    }
  }, [shifts, isOperator])

  const selectedShift = shifts.find((s) => s.id === selectedShiftId)

  return (
    <>
      <div className="page-header d-print-none">
        <div className="container-xl">
          <div className="row g-2 align-items-center">
            <div className="col">
              <h2 className="page-title">Attendance</h2>
              <div className="text-muted mt-1">{activeSPBU?.name}</div>
            </div>
          </div>
        </div>
      </div>

      <div className="page-body">
        <div className="container-xl">
          {isOperator ? (
            <div className="alert alert-info py-2 mb-3 small">
              <i className="ti ti-clock me-1"></i>
              <strong>{formatDate(today)}</strong>
              {selectedShift && <> &nbsp;·&nbsp; Shift: <strong>{selectedShift.nama} ({selectedShift.jam_mulai}–{selectedShift.jam_selesai})</strong></>}
            </div>
          ) : (
            <div className="card mb-3">
              <div className="card-body">
                <div className="row g-3 align-items-end">
                  <div className="col-auto">
                    <label className="form-label">Tanggal</label>
                    <input
                      type="date"
                      className="form-control"
                      value={tanggal}
                      onChange={(e) => setTanggal(e.target.value)}
                    />
                  </div>
                  <div className="col-auto">
                    <label className="form-label">Shift</label>
                    {shiftsLoading ? (
                      <div className="form-control bg-light" style={{ width: 160 }}>
                        <span className="spinner-border spinner-border-sm"></span>
                      </div>
                    ) : (
                      <select
                        className="form-select"
                        value={selectedShiftId ?? ''}
                        onChange={(e) => setSelectedShiftId(Number(e.target.value))}
                      >
                        {shifts.map((s) => (
                          <option key={s.id} value={s.id}>
                            {s.nama} ({s.jam_mulai}–{s.jam_selesai})
                          </option>
                        ))}
                      </select>
                    )}
                  </div>
                </div>
              </div>
            </div>
          )}

          {slotLoading ? (
            <div className="text-center py-5">
              <div className="spinner-border text-primary"></div>
            </div>
          ) : selectedShiftId ? (
            <div className="row g-3">
              <div className="col-md-4">
                <div className="card h-100">
                  <div className="card-header">
                    <h3 className="card-title">Jadwal Operator</h3>
                  </div>
                  <div className="card-body">
                    {slot?.operators && slot.operators.length > 0 ? (
                      <ul className="list-unstyled mb-0">
                        {slot.operators.map((op) => (
                          <li key={op.id} className="d-flex align-items-center gap-2 py-1">
                            <span className="avatar avatar-sm bg-blue-lt text-blue" style={{ fontSize: 12 }}>
                              {op.nama.charAt(0).toUpperCase()}
                            </span>
                            <span>{op.nama}</span>
                          </li>
                        ))}
                      </ul>
                    ) : (
                      <p className="text-muted small mb-0">
                        Tidak ada jadwal operator untuk slot ini.
                      </p>
                    )}
                  </div>
                </div>
              </div>

              <div className="col-md-8">
                <div className="card h-100">
                  <div className="card-header">
                    <h3 className="card-title">
                      Bukti Hadir — {selectedShift?.nama ?? `Shift #${selectedShiftId}`}
                      <span className="text-muted ms-2 fw-normal">{formatDate(tanggal)}</span>
                    </h3>
                  </div>
                  <div className="card-body">
                    {slot?.absensi ? (
                      <AbsensiCard
                        absensi={slot.absensi}
                        spbuId={spbuId!}
                        canApprove={canApprove}
                        canDelete={canDelete}
                        onApprove={(id) => approve.mutate(id)}
                        onDelete={(id) => deleteAbsensi.mutate(id)}
                      />
                    ) : (
                      <div>
                        <p className="text-muted small mb-3">
                          Belum ada foto absensi untuk shift ini. Upload foto untuk mencatat kehadiran.
                        </p>
                        <UploadPanel
                          spbuId={spbuId!}
                          shiftId={selectedShiftId}
                          tanggal={tanggal}
                          shiftNama={selectedShift?.nama}
                        />
                      </div>
                    )}
                  </div>
                </div>
              </div>
            </div>
          ) : null}

          {!isOperator && spbuId && (
            <PendingApprovals
              spbuId={spbuId}
              start={monthStart}
              end={monthEnd}
              canApprove={canApprove}
              canDelete={canDelete}
            />
          )}
        </div>
      </div>
    </>
  )
}
```

- [ ] **Step 8.2: Commit**

```bash
git add frontend/src/app/(dashboard)/general-affairs/absensi/absensi-client.tsx
git commit -m "feat: add permission gates + delete + ClickableImage to absensi page"
```

---

## Task 9: Frontend — Update housekeeping page

**Files:**
- Modify: `frontend/src/app/(dashboard)/general-affairs/housekeeping/housekeeping-client.tsx`

- [ ] **Step 9.1: Add imports and hooks to housekeeping-client.tsx**

At the top of the file, update the import block:

```tsx
'use client'

import { useState, useRef, useEffect } from 'react'
import {
  useHousekeeping,
  useHousekeepingSlot,
  useSubmitHousekeeping,
  useApproveHousekeeping,
  useDeleteHousekeeping,
  useIsOperatorMode,
} from '@/lib/hooks/useGeneralAffairs'
import { usePermission } from '@/lib/hooks/usePermission'
import { useShifts } from '@/lib/hooks/useSPBUs'
import { useSpbuStore } from '@/stores/spbu-store'
import { ClickableImage } from '@/components/ui/image-lightbox'
import { isMobileDevice } from '@/lib/utils/device'
import { getActiveShift } from '@/lib/utils/shift'
import type { Housekeeping, HousekeepingFoto, Shift } from '@/types'
```

- [ ] **Step 9.2: Update `PhotoGrid` to use `ClickableImage`**

In `PhotoGrid`, replace the existing photos render (the `fotos.map` section):

```tsx
{fotos.map((f) => (
  <ClickableImage
    key={f.id}
    src={f.foto_url}
    alt={label}
    style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 6 }}
  />
))}
```

(Was `<a href target="_blank"><img></a>` — replace entire `<a>` with `<ClickableImage>`.)

- [ ] **Step 9.3: Add `canApprove` and `canDelete` to `HousekeepingClient` and thread through to `PendingApprovals`**

In `HousekeepingClient`, add after `const isOperator = useIsOperatorMode()`:

```tsx
const canApprove = usePermission('housekeeping', 'approve')
const canDelete = usePermission('housekeeping', 'delete')
```

Update the `PendingApprovals` call at the bottom of the component:

```tsx
{!isOperator && spbuId && (
  <PendingApprovals
    spbuId={spbuId}
    start={monthStart}
    end={monthEnd}
    canApprove={canApprove}
    canDelete={canDelete}
  />
)}
```

- [ ] **Step 9.4: Update `PendingApprovals` component signature and add delete functionality**

Replace the `PendingApprovals` function signature and body:

```tsx
function PendingApprovals({
  spbuId,
  start,
  end,
  canApprove,
  canDelete,
}: {
  spbuId: number
  start: string
  end: string
  canApprove: boolean
  canDelete: boolean
}) {
  const { data: list = [] } = useHousekeeping(spbuId, start, end)
  const approve = useApproveHousekeeping(spbuId)
  const deleteHk = useDeleteHousekeeping(spbuId)
  const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null)
  const pending = list.filter((h) => h.status === 'pending')

  if (pending.length === 0) return null

  return (
    <div className="card mt-3">
      <div className="card-header">
        <h3 className="card-title">Menunggu Persetujuan</h3>
        <span className="badge bg-warning text-dark ms-2">{pending.length}</span>
      </div>
      <div className="table-responsive">
        <table className="table table-vcenter card-table">
          <thead>
            <tr>
              <th>Tanggal</th>
              <th>Shift</th>
              <th>Deskripsi</th>
              <th>Foto</th>
              <th>Oleh</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            {pending.map((h) => (
              <tr key={h.id}>
                <td>{formatDate(h.tanggal)}</td>
                <td>{h.shift_nama ?? `#${h.shift_id}`}</td>
                <td className="small text-muted">
                  {h.items.length > 0
                    ? h.items.slice(0, 2).map((i) => i.deskripsi).join(', ') +
                      (h.items.length > 2 ? '…' : '')
                    : '—'}
                </td>
                <td>
                  <div className="d-flex gap-1">
                    {h.fotos.slice(0, 3).map((f) => (
                      <ClickableImage
                        key={f.id}
                        src={f.foto_url}
                        alt={f.tipe}
                        style={{ width: 36, height: 36, objectFit: 'cover', borderRadius: 4 }}
                      />
                    ))}
                    {h.fotos.length > 3 && (
                      <span className="text-muted small align-self-center">+{h.fotos.length - 3}</span>
                    )}
                  </div>
                </td>
                <td className="text-muted small">{h.uploaded_by_nama ?? '—'}</td>
                <td className="text-end">
                  <div className="d-flex gap-2 justify-content-end">
                    {canApprove && (
                      <button
                        className="btn btn-sm btn-success"
                        disabled={approve.isPending}
                        onClick={() => approve.mutate(h.id)}
                      >
                        <i className="ti ti-check me-1"></i>Setujui
                      </button>
                    )}
                    {canDelete && confirmDeleteId !== h.id && (
                      <button
                        className="btn btn-sm btn-ghost-danger"
                        onClick={() => setConfirmDeleteId(h.id)}
                      >
                        <i className="ti ti-trash"></i>
                      </button>
                    )}
                    {canDelete && confirmDeleteId === h.id && (
                      <>
                        <button
                          className="btn btn-sm btn-danger"
                          disabled={deleteHk.isPending}
                          onClick={() => { deleteHk.mutate(h.id); setConfirmDeleteId(null) }}
                        >
                          Ya, Hapus
                        </button>
                        <button
                          className="btn btn-sm btn-ghost-secondary"
                          onClick={() => setConfirmDeleteId(null)}
                        >
                          Batal
                        </button>
                      </>
                    )}
                  </div>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  )
}
```

- [ ] **Step 9.5: Commit**

```bash
git add frontend/src/app/(dashboard)/general-affairs/housekeeping/housekeeping-client.tsx
git commit -m "feat: add permission gates + delete + ClickableImage to housekeeping page"
```

---

## Task 10: Frontend — Permission gates on jadwal page

**Files:**
- Modify: `frontend/src/app/(dashboard)/general-affairs/jadwal/jadwal-client.tsx`

- [ ] **Step 10.1: Add `usePermission` import and hooks**

At the top of `jadwal-client.tsx`, add to the imports:

```tsx
import { usePermission } from '@/lib/hooks/usePermission'
```

In `JadwalClient`, after the existing hook declarations, add:

```tsx
const canCreate = usePermission('jadwal', 'create')
const canDelete = usePermission('jadwal', 'delete')
```

- [ ] **Step 10.2: Pass permission flags to `ShiftCell`**

Update `ShiftCell` props interface to accept `canCreate` and `canDelete`:

```tsx
function ShiftCell({
  jadwalId,
  shiftNama,
  shifts,
  isOpen,
  onOpen,
  onClose,
  onAssign,
  onRemove,
  canCreate,
  canDelete,
}: {
  jadwalId?: number
  shiftNama?: string
  shifts: Shift[]
  isOpen: boolean
  onOpen: (el: HTMLElement) => void
  onClose: () => void
  onAssign: (shiftId: number) => void
  onRemove: () => void
  canCreate: boolean
  canDelete: boolean
}) {
```

In the `ShiftCell` render body, gate the delete button:

```tsx
if (jadwalId && shiftNama) {
  return (
    <div className="d-flex align-items-center justify-content-center gap-1" style={{ minWidth: 80 }}>
      <span className="badge bg-blue-lt text-blue small">{shiftNama}</span>
      {canDelete && (
        <button
          className="btn btn-ghost-danger btn-sm p-0 px-1"
          style={{ fontSize: '0.7rem', lineHeight: 1 }}
          onClick={onRemove}
          title="Hapus jadwal"
        >
          <i className="ti ti-x"></i>
        </button>
      )}
    </div>
  )
}
```

Gate the add button:

```tsx
return (
  <>
    {canCreate && (
      <button
        ref={btnRef}
        className="btn btn-ghost-secondary btn-sm"
        style={{ fontSize: '0.7rem', minWidth: 60 }}
        onClick={() => {
          if (isOpen) {
            onClose()
          } else if (btnRef.current) {
            onOpen(btnRef.current)
          }
        }}
      >
        <i className="ti ti-plus"></i>
      </button>
    )}
    {canCreate && isOpen && btnRef.current && (
      <ShiftDropdown
        shifts={shifts}
        anchorEl={btnRef.current}
        onSelect={onAssign}
        onClose={onClose}
      />
    )}
  </>
)
```

- [ ] **Step 10.3: Pass flags through in `JadwalClient` render**

In the table body where `ShiftCell` is rendered, add the two new props:

```tsx
<ShiftCell
  jadwalId={entry?.id}
  shiftNama={entry?.shift_nama}
  shifts={shifts}
  isOpen={openCell === cellKey}
  onOpen={() => setOpenCell(cellKey)}
  onClose={() => setOpenCell(null)}
  onAssign={(shiftId) => handleAssign(op.id, iso, shiftId)}
  onRemove={() => entry && handleRemove(entry.id)}
  canCreate={canCreate}
  canDelete={canDelete}
/>
```

- [ ] **Step 10.4: Commit**

```bash
git add frontend/src/app/(dashboard)/general-affairs/jadwal/jadwal-client.tsx
git commit -m "feat: add permission gates to jadwal page (create/delete)"
```

---

## Task 11: Frontend — Collapsible sidebar sections

**Files:**
- Modify: `frontend/src/components/ui/sidebar.tsx`

- [ ] **Step 11.1: Rewrite `sidebar.tsx`**

Replace the entire content of `frontend/src/components/ui/sidebar.tsx`:

```tsx
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useSPBUs } from '@/lib/hooks/useSPBUs'
import { useMe } from '@/lib/hooks/useAuth'
import { useSpbuStore } from '@/stores/spbu-store'

const NAV_OPERATIONAL = [
  { href: '/dashboard', nav: 'dashboard', icon: 'ti-dashboard', label: 'Dashboard' },
  { href: '/track', nav: 'track', icon: 'ti-calendar-stats', label: 'Track Your Day' },
  { href: '/penjualan', nav: 'penjualan', icon: 'ti-gas-station', label: 'Sales' },
  { href: '/stock', nav: 'stock', icon: 'ti-cylinder', label: 'Stock Adjustment' },
  { href: '/penebusan', nav: 'penebusan', icon: 'ti-truck-delivery', label: 'Fuel Purchase' },
  { href: '/penerimaan', nav: 'penerimaan', icon: 'ti-arrow-bar-down', label: 'Fuel Delivery' },
  { href: '/expenses', nav: 'expenses', icon: 'ti-receipt', label: 'Expenses' },
  { href: '/penyetoran', nav: 'penyetoran', icon: 'ti-cash', label: 'Cash Deposit' },
  { href: '/rekonsiliasi', nav: 'rekonsiliasi', icon: 'ti-chart-dots', label: 'Reconciliation' },
]

const NAV_LAPORAN = [
  { href: '/laporan', nav: 'laporan', icon: 'ti-file-analytics', label: 'Reports' },
  { href: '/analytics', nav: 'analytics', icon: 'ti-chart-bar', label: 'Analytics' },
  { href: '/anomali', nav: 'anomali', icon: 'ti-alert-triangle', label: 'Anomalies' },
]

const NAV_MASTER = [
  { href: '/spbu', nav: 'spbu', icon: 'ti-building', label: 'Stations' },
  { href: '/products', nav: 'products', icon: 'ti-tag', label: 'Products' },
  { href: '/users', nav: 'users', icon: 'ti-users', label: 'Users' },
]

const NAV_GA = [
  { href: '/general-affairs/operators', nav: 'ga-operators', icon: 'ti-user-check', label: 'Operators' },
  { href: '/general-affairs/jadwal', nav: 'ga-jadwal', icon: 'ti-calendar-week', label: 'Schedule' },
  { href: '/general-affairs/absensi', nav: 'ga-absensi', icon: 'ti-camera', label: 'Attendance' },
  { href: '/general-affairs/housekeeping', nav: 'ga-housekeeping', icon: 'ti-sparkles', label: 'Housekeeping' },
]

type SectionKey = 'operations' | 'general-affairs' | 'reports' | 'master'

function getActiveSection(pathname: string): SectionKey {
  if (
    pathname === '/dashboard' ||
    pathname.startsWith('/track') ||
    pathname.startsWith('/penjualan') ||
    pathname.startsWith('/stock') ||
    pathname.startsWith('/penebusan') ||
    pathname.startsWith('/penerimaan') ||
    pathname.startsWith('/expenses') ||
    pathname.startsWith('/penyetoran') ||
    pathname.startsWith('/rekonsiliasi')
  ) return 'operations'
  if (pathname.startsWith('/general-affairs')) return 'general-affairs'
  if (
    pathname.startsWith('/laporan') ||
    pathname.startsWith('/analytics') ||
    pathname.startsWith('/anomali')
  ) return 'reports'
  return 'master'
}

const STORAGE_KEY = 'sidebar-sections'
const DEFAULT_COLLAPSED: SectionKey[] = ['general-affairs', 'reports', 'master']

export function Sidebar() {
  const pathname = usePathname()
  const { data: spbus } = useSPBUs()
  const { data: me } = useMe()
  const { activeSPBU, setActiveSPBU } = useSpbuStore()

  const [collapsed, setCollapsed] = useState<Set<SectionKey>>(() => {
    if (typeof window === 'undefined') return new Set(DEFAULT_COLLAPSED)
    try {
      const saved = localStorage.getItem(STORAGE_KEY)
      return saved
        ? new Set(JSON.parse(saved) as SectionKey[])
        : new Set<SectionKey>(DEFAULT_COLLAPSED)
    } catch {
      return new Set<SectionKey>(DEFAULT_COLLAPSED)
    }
  })

  // Auto-expand active section on pathname change
  useEffect(() => {
    const active = getActiveSection(pathname)
    setCollapsed((prev) => {
      if (!prev.has(active)) return prev
      const next = new Set(prev)
      next.delete(active)
      localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]))
      return next
    })
  }, [pathname])

  useEffect(() => {
    if (spbus && spbus.length > 0 && !activeSPBU) {
      setActiveSPBU(spbus[0])
    }
  }, [spbus, activeSPBU, setActiveSPBU])

  function toggleSection(key: SectionKey) {
    setCollapsed((prev) => {
      const next = new Set(prev)
      if (next.has(key)) next.delete(key)
      else next.add(key)
      localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]))
      return next
    })
  }

  const getActiveNav = () => {
    if (pathname.startsWith('/users/roles')) return 'roles'
    if (pathname.startsWith('/users')) return 'users'
    if (pathname.startsWith('/settings')) return 'settings'
    if (pathname.startsWith('/products')) return 'products'
    if (pathname.startsWith('/spbu')) return 'spbu'
    if (pathname.startsWith('/penjualan')) return 'penjualan'
    if (pathname.startsWith('/stock')) return 'stock'
    if (pathname.startsWith('/penebusan')) return 'penebusan'
    if (pathname.startsWith('/penerimaan')) return 'penerimaan'
    if (pathname.startsWith('/expenses')) return 'expenses'
    if (pathname.startsWith('/penyetoran')) return 'penyetoran'
    if (pathname.startsWith('/rekonsiliasi')) return 'rekonsiliasi'
    if (pathname.startsWith('/laporan')) return 'laporan'
    if (pathname.startsWith('/analytics')) return 'analytics'
    if (pathname.startsWith('/anomali')) return 'anomali'
    if (pathname.startsWith('/track')) return 'track'
    if (pathname === '/dashboard') return 'dashboard'
    if (pathname.startsWith('/general-affairs/operators')) return 'ga-operators'
    if (pathname.startsWith('/general-affairs/jadwal')) return 'ga-jadwal'
    if (pathname.startsWith('/general-affairs/absensi')) return 'ga-absensi'
    if (pathname.startsWith('/general-affairs/housekeeping')) return 'ga-housekeeping'
    return ''
  }

  const activeNav = getActiveNav()

  function SectionToggle({ sectionKey, label }: { sectionKey: SectionKey; label: string }) {
    const isCollapsed = collapsed.has(sectionKey)
    return (
      <li>
        <button
          className="w-100 d-flex align-items-center justify-content-between border-0 bg-transparent px-0"
          style={{ cursor: 'pointer', padding: '0.25rem 0' }}
          onClick={() => toggleSection(sectionKey)}
        >
          <span className="section-label mb-0" style={{ marginBottom: 0 }}>{label}</span>
          <i className={`ti ${isCollapsed ? 'ti-chevron-right' : 'ti-chevron-down'} text-muted`} style={{ fontSize: '0.7rem' }}></i>
        </button>
      </li>
    )
  }

  return (
    <aside className="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
      <div className="container-fluid">
        <button
          className="navbar-toggler"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#navbar-menu"
        >
          <span className="navbar-toggler-icon"></span>
        </button>
        <h1 className="navbar-brand navbar-brand-autodark">
          <Link href="/">
            <span className="navbar-brand-text">
              ⛽ SPBU<span style={{ color: '#4da6ff' }}>Manager</span>
            </span>
          </Link>
        </h1>

        <div className="collapse navbar-collapse" id="navbar-menu">
          {spbus && spbus.length > 0 && (
            <div className="mb-2 mt-lg-2">
              <select
                className="form-select form-select-sm"
                style={{
                  background: 'rgba(255,255,255,0.08)',
                  color: 'rgba(255,255,255,0.8)',
                  borderColor: 'rgba(255,255,255,0.15)',
                }}
                value={activeSPBU?.id ?? ''}
                onChange={(e) => {
                  const spbu = spbus.find((s) => s.id === Number(e.target.value))
                  if (spbu) setActiveSPBU(spbu)
                }}
              >
                {spbus.map((s) => (
                  <option key={s.id} value={s.id}>
                    {s.nomor_pertamina} — {s.name}
                  </option>
                ))}
              </select>
            </div>
          )}

          <ul className="navbar-nav pt-lg-2">
            {activeSPBU && (
              <>
                <SectionToggle sectionKey="operations" label="Operations" />
                {!collapsed.has('operations') && NAV_OPERATIONAL.map((item) => (
                  <li key={item.nav} className="nav-item">
                    <Link href={item.href} className={`nav-link ${activeNav === item.nav ? 'active' : ''}`}>
                      <span className="nav-link-icon d-md-none d-lg-inline-block">
                        <i className={`ti ${item.icon}`}></i>
                      </span>
                      <span className="nav-link-title">{item.label}</span>
                    </Link>
                  </li>
                ))}

                <SectionToggle sectionKey="general-affairs" label="General Affairs" />
                {!collapsed.has('general-affairs') && NAV_GA.map((item) => (
                  <li key={item.nav} className="nav-item">
                    <Link href={item.href} className={`nav-link ${activeNav === item.nav ? 'active' : ''}`}>
                      <span className="nav-link-icon d-md-none d-lg-inline-block">
                        <i className={`ti ${item.icon}`}></i>
                      </span>
                      <span className="nav-link-title">{item.label}</span>
                    </Link>
                  </li>
                ))}

                <SectionToggle sectionKey="reports" label="Reports" />
                {!collapsed.has('reports') && NAV_LAPORAN.map((item) => (
                  <li key={item.nav} className="nav-item">
                    <Link href={item.href} className={`nav-link ${activeNav === item.nav ? 'active' : ''}`}>
                      <span className="nav-link-icon d-md-none d-lg-inline-block">
                        <i className={`ti ${item.icon}`}></i>
                      </span>
                      <span className="nav-link-title">{item.label}</span>
                    </Link>
                  </li>
                ))}
              </>
            )}

            <SectionToggle sectionKey="master" label="Master Data" />
            {!collapsed.has('master') && (
              <>
                {NAV_MASTER.map((item) => (
                  <li key={item.nav} className="nav-item">
                    <Link href={item.href} className={`nav-link ${activeNav === item.nav ? 'active' : ''}`}>
                      <span className="nav-link-icon d-md-none d-lg-inline-block">
                        <i className={`ti ${item.icon}`}></i>
                      </span>
                      <span className="nav-link-title">{item.label}</span>
                    </Link>
                  </li>
                ))}
                {me?.is_superadmin && (
                  <li className="nav-item">
                    <Link href="/settings" className={`nav-link ${activeNav === 'settings' ? 'active' : ''}`}>
                      <span className="nav-link-icon d-md-none d-lg-inline-block">
                        <i className="ti ti-settings"></i>
                      </span>
                      <span className="nav-link-title">Settings</span>
                    </Link>
                  </li>
                )}
              </>
            )}
          </ul>
        </div>
      </div>
    </aside>
  )
}
```

- [ ] **Step 11.2: Commit**

```bash
git add frontend/src/components/ui/sidebar.tsx
git commit -m "feat: collapsible sidebar sections with localStorage persistence"
```

---

## Self-Review

After writing, checked spec coverage:

| Spec requirement | Task |
|-----------------|------|
| Extend /me with assignments + permissions | Task 1 |
| DELETE absensi (pending-only) backend | Task 3 |
| DELETE housekeeping (pending-only) backend | Task 3 |
| Remove is_dev_mode from spbu_service (7x) | Task 2 |
| Remove is_dev_mode from user_service | Task 2 |
| Update CLAUDE.md rule 18 | Task 4 |
| `UserSpbuAssignment` type updated | Task 5 |
| `usePermission` hook | Task 5 |
| `PermissionGuard` component | Task 5 |
| `useIsOperatorMode` fix | Task 5 |
| `useDeleteAbsensi` + `useDeleteHousekeeping` hooks | Task 6 |
| `gaApi.deleteAbsensi` + `gaApi.deleteHousekeeping` | Task 6 |
| ImageLightbox + ClickableImage component | Task 7 |
| Mount ImageLightbox in layout | Task 7 |
| Absensi page: permission gates + delete + ClickableImage | Task 8 |
| Housekeeping page: permission gates + delete + ClickableImage | Task 9 |
| Jadwal page: permission gates | Task 10 |
| Sidebar collapsible with localStorage | Task 11 |

All spec requirements covered. No TBDs or placeholders.
