# UI Improvements Design
**Date:** 2026-04-11
**Status:** Approved

## Overview

Four improvements to SPBU Manager:
1. Permission-gated CRUD actions across all forms and lists
2. Revised soft/hard delete strategy
3. Image lightbox modal (click-to-enlarge for any image)
4. Collapsible sidebar sections

---

## 1. Permission-gated CRUD

### Problem
`/me` endpoint returns no permission data. There is no `usePermission` hook. UI action buttons (delete, approve, edit) are shown to all users regardless of their role.

### Backend changes

**Extend `UserResponse` in `backend/app/schemas/auth.py`:**

```python
class UserAssignmentWithPermissions(BaseModel):
    spbu_id: int
    spbu_name: str
    role_id: int
    role_name: str
    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] = []
```

The `/me` handler joins `user_spbu_assignments → roles → role_permissions` to build this list.

**New DELETE endpoints in `backend/app/routers/general_affairs.py`:**

```
DELETE /api/v1/spbus/{spbu_id}/absensi/{absensi_id}
DELETE /api/v1/spbus/{spbu_id}/housekeeping/{hk_id}
```

Guard: status must be `pending`. If `approved` → `400 "Tidak dapat menghapus data yang sudah disetujui"`.

Requires `absensi:delete` and `housekeeping:delete` permissions respectively.

### Frontend changes

**Update `User` type in `frontend/src/types/index.ts`:**

```ts
interface UserAssignment {
  spbu_id: number
  spbu_name: string
  role_id: number
  role_name: string
  permissions: string[]
}

interface User {
  id: number
  name: string
  email: string
  is_superadmin: boolean
  is_active: boolean
  assignments: UserAssignment[]
}
```

**New hook `frontend/src/lib/hooks/usePermission.ts`:**

```ts
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
}
```

**Pages updated:**
- `absensi-client.tsx` — delete button (pending only) gated by `absensi:delete`; approve button gated by `absensi:approve`
- `housekeeping-client.tsx` — delete button (pending only) gated by `housekeeping:delete`; approve button gated by `housekeeping:approve`
- `jadwal-client.tsx` — delete button gated by `jadwal:delete`; add-schedule gated by `jadwal:create`
- `operators-client.tsx` — read-only, no CRUD changes (operators managed via `/users`)

**New `PermissionGuard` component** in `frontend/src/components/ui/permission-guard.tsx`:

```tsx
// Renders children only if user has the given permission.
// Renders null otherwise. For superadmin → always renders.
export function PermissionGuard({
  modul, aksi, children
}: { modul: ModulType; aksi: AksiType; children: React.ReactNode }) {
  const can = usePermission(modul, aksi)
  return can ? <>{children}</> : null
}
```

Already established as the project coding convention in `frontend/CLAUDE.md`.

**New mutation hooks needed:**
- `useDeleteAbsensi(spbuId)` in `useGeneralAffairs.ts`
- `useDeleteHousekeeping(spbuId)` in `useGeneralAffairs.ts`

---

## 2. Delete Strategy

### New rule (replaces dev/prod environment_mode distinction for deletes)

**Soft delete — always, regardless of environment:**

| Table | Notes |
|-------|-------|
| `users` | |
| `roles` | |
| `master_spbu` | |
| `master_spbu_shift` | |
| `master_spbu_island` | |
| `master_spbu_nozzle` | |
| `master_spbu_tangki` | |
| `master_produk` | |
| `produk_harga` | |
| `master_spbu_tenant` | |
| `master_spbu_kontrak` | |
| `expense_kategori` | |

**Hard delete with orphan check — always, regardless of environment:**

All operational/transactional tables: `jadwal`, `absensi`, `housekeeping`, `housekeeping_item`, `housekeeping_foto`, `laporan_shift`, `penjualan_nozzle`, `stock_adjustment`, `stock_adjustment_item`, `stock_adjustment_item_foto`, `penebusan`, `penebusan_item`, `penerimaan`, `penerimaan_item`, `penerimaan_foto`, `expenses`, `penyetoran`, `master_spbu_kalibrasi`, `end_to_end_cycle`.

### Orphan check pattern

For relationships that can be predicted, check explicitly before delete and return a friendly message:

```python
count = await repo.count_related(db, record_id)
if count > 0:
    raise ValueError(
        f"Tidak dapat menghapus data ini karena masih ada {count} "
        "data terkait yang bergantung padanya."
    )
```

For unexpected FK violations, catch `IntegrityError` at service layer:

```python
from sqlalchemy.exc import IntegrityError
try:
    await repo.hard_delete(db, record_id)
except IntegrityError:
    raise ValueError("Tidak dapat menghapus data ini karena masih ada data terkait.")
```

### `environment_mode` updated purpose

Remove `environment_mode` from all delete logic. The config key is retained in `system_config` as a **general dev flag** for future dev-only features (data seeding, debug panels, etc.) — not for controlling delete behavior.

**Backend files that call `system_repository.is_dev_mode()` for delete logic (to be updated):**
- `spbu_service.py` — 7 calls: `delete_spbu`, `delete_shift`, `delete_island`, `delete_nozzle`, `delete_tangki`, `delete_tenant`, `delete_kontrak`. All master data → remove call, always soft delete.
- `user_service.py` — 1 call: `delete_user`. Master data → same fix.

Update `CLAUDE.md` rule 18 to reflect this.

---

## 3. Image Lightbox Modal

### Problem
All images currently open via `<a href="..." target="_blank">` — new tab, no context. Need click-to-enlarge in a modal.

### New file: `frontend/src/components/ui/image-lightbox.tsx`

Three exports:

**`useLightboxStore`** — Zustand slice (defined inline in the file):
```ts
interface LightboxState {
  src: string | null
  alt: string
  open: (src: string, alt?: string) => void
  close: () => void
}
```

**`ImageLightbox`** — modal component, mounted once in the dashboard layout:
- Renders via React portal to `document.body`
- Bootstrap modal classes (`modal modal-blur fade show d-block`) consistent with existing modals
- Backdrop click → close
- X button → close
- Keyboard `ESC` → close
- Image: `max-height: 90vh`, `max-width: 90vw`, `object-fit: contain`
- Only renders when `src !== null`

**`ClickableImage`** — drop-in wrapper component:
```tsx
// Renders a normal <img> with cursor: pointer
// onClick triggers useLightboxStore.open(src, alt)
// Accepts all standard img props
<ClickableImage src={url} alt="foto" style={{ width: 80, height: 80, objectFit: 'cover' }} />
```

### Mount point

`ImageLightbox` added once to `frontend/src/app/(dashboard)/layout.tsx`. No changes needed in individual pages beyond replacing `<a><img></a>` with `<ClickableImage>`.

### Migration

Replace in:
- `absensi-client.tsx`: AbsensiCard thumbnail + pending approvals table thumbnails
- `housekeeping-client.tsx`: PhotoGrid saved photos + pending approvals table thumbnails

---

## 4. Collapsible Sidebar Sections

### Behavior
- Active section (determined by current pathname) is always expanded
- Navigating to a page auto-expands its section if collapsed
- All sections can be manually toggled
- Collapsed state persisted in `localStorage` key `sidebar-sections`
- Default on first visit: `general-affairs`, `reports`, `master` collapsed; `operations` open

### Section → pathname mapping

```ts
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'  // /spbu, /products, /users, /settings
}
```

### State logic in `Sidebar` component

```ts
const [collapsed, setCollapsed] = useState<Set<SectionKey>>(() => {
  try {
    const saved = localStorage.getItem('sidebar-sections')
    return saved
      ? new Set(JSON.parse(saved) as SectionKey[])
      : new Set<SectionKey>(['general-affairs', 'reports', 'master'])
  } catch {
    return new Set<SectionKey>(['general-affairs', 'reports', 'master'])
  }
})

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

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

### Section header markup

```tsx
<li>
  <button
    className="section-label-toggle w-100 d-flex align-items-center justify-content-between border-0 bg-transparent px-0"
    onClick={() => toggleSection('operations')}
    style={{ cursor: 'pointer' }}
  >
    <span className="section-label mb-0">Operations</span>
    <i className={`ti ${collapsed.has('operations') ? 'ti-chevron-right' : 'ti-chevron-down'} text-muted small`}></i>
  </button>
</li>
{!collapsed.has('operations') && NAV_OPERATIONAL.map(...)}
```

Implementation uses React state directly — not Bootstrap `data-bs-toggle` — to enable localStorage sync and auto-expand logic.

---

## Files Changed Summary

### Backend
- `backend/app/schemas/auth.py` — extend `UserResponse` with assignments + permissions
- `backend/app/routers/auth.py` — update `/me` handler to join permissions via repository
- `backend/app/repositories/user_repository.py` — add query to fetch assignments with flattened permissions
- `backend/app/routers/general_affairs.py` — add `DELETE absensi/{id}` and `DELETE housekeeping/{id}`
- `backend/app/services/general_affairs_service.py` — add `delete_absensi`, `delete_housekeeping` with pending-only guard
- `backend/app/repositories/general_affairs_repository.py` — add `hard_delete_absensi`, `hard_delete_housekeeping`
- `backend/app/services/spbu_service.py` — remove `is_dev_mode` from 7 delete functions → always soft delete
- `backend/app/services/user_service.py` — remove `is_dev_mode` from `delete_user` → always soft delete
- `CLAUDE.md` — update rule 18

### Frontend
- `frontend/src/types/index.ts` — update `User` type with `assignments`
- `frontend/src/lib/hooks/usePermission.ts` — new: `usePermission(modul, aksi)` hook
- `frontend/src/components/ui/permission-guard.tsx` — new: `PermissionGuard` component
- `frontend/src/lib/hooks/useGeneralAffairs.ts` — add `useDeleteAbsensi`, `useDeleteHousekeeping`
- `frontend/src/components/ui/image-lightbox.tsx` — new: `ImageLightbox`, `ClickableImage`, `useLightboxStore`
- `frontend/src/components/ui/sidebar.tsx` — collapsible sections with localStorage state
- `frontend/src/app/(dashboard)/layout.tsx` — mount `<ImageLightbox />` once
- `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` — gate `+` cell button (`jadwal:create`) and `x` badge button (`jadwal:delete`)
- `CLAUDE.md` — update rule 18 re: delete strategy and environment_mode
