# General Affairs 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:** Implementasi modul General Affairs (Operators, Jadwal Shift, Absensi) lengkap dari DB migration hingga frontend pages.

**Architecture:** Backend menggunakan pola router → service → repository → model. Tiga tabel baru (`jadwal_shift`, `absensi`) plus kolom `can_be_scheduled` di `master_role`. Frontend Next.js App Router dengan TanStack Query untuk server state dan Tabler/Bootstrap UI.

**Tech Stack:** FastAPI, SQLAlchemy async, Alembic, Pillow, Next.js 16 App Router, TanStack Query, Zustand, Tabler UI (Bootstrap)

---

## File Map

### Backend — New Files
- `backend/alembic/versions/x0b1c2d3e4f5_add_general_affairs.py` — migration
- `backend/app/models/general_affairs.py` — JadwalShift, Absensi ORM models
- `backend/app/schemas/general_affairs.py` — Pydantic schemas
- `backend/app/repositories/general_affairs_repository.py` — DB queries
- `backend/app/services/general_affairs_service.py` — business logic
- `backend/app/routers/general_affairs.py` — API endpoints
- `backend/app/utils/image.py` — compress_image() utility

### Backend — Modified Files
- `backend/app/models/role.py` — add `can_be_scheduled` + 3 new ModulEnum values
- `backend/app/schemas/role.py` — add `can_be_scheduled` to RoleUpdate + RoleResponse
- `backend/app/utils/seed.py` — add GA permissions to default roles + set can_be_scheduled
- `backend/app/main.py` — register general_affairs router
- `backend/requirements.txt` — add Pillow

### Frontend — New Files
- `frontend/src/lib/api/general-affairs.ts` — API client
- `frontend/src/lib/hooks/useGeneralAffairs.ts` — TanStack Query hooks
- `frontend/src/app/(dashboard)/general-affairs/layout.tsx` — sub-nav layout
- `frontend/src/app/(dashboard)/general-affairs/operators/page.tsx`
- `frontend/src/app/(dashboard)/general-affairs/operators/operators-client.tsx`
- `frontend/src/app/(dashboard)/general-affairs/jadwal/page.tsx`
- `frontend/src/app/(dashboard)/general-affairs/jadwal/jadwal-client.tsx`
- `frontend/src/app/(dashboard)/general-affairs/absensi/page.tsx`
- `frontend/src/app/(dashboard)/general-affairs/absensi/absensi-client.tsx`

### Frontend — Modified Files
- `frontend/src/types/index.ts` — add ModulType values, Role.can_be_scheduled, new interfaces
- `frontend/src/components/ui/sidebar.tsx` — add General Affairs nav group
- `frontend/src/app/(dashboard)/users/roles/roles-client.tsx` — add can_be_scheduled toggle + new modules
- `frontend/src/lib/api/roles.ts` — add can_be_scheduled to update payload
- `frontend/src/lib/hooks/useRoles.ts` — add useUpdateCanBeScheduled

---

## Task 1: Alembic Migration

**Files:**
- Create: `backend/alembic/versions/x0b1c2d3e4f5_add_general_affairs.py`

- [ ] **Step 1: Create migration file**

```python
# backend/alembic/versions/x0b1c2d3e4f5_add_general_affairs.py
"""Add General Affairs: can_be_scheduled, jadwal_shift, absensi.

Revision ID: x0b1c2d3e4f5
Revises: w9a0b1c2d3e4
Create Date: 2026-04-10
"""

from alembic import op
import sqlalchemy as sa

revision = "x0b1c2d3e4f5"
down_revision = "w9a0b1c2d3e4"
branch_labels = None
depends_on = None


def upgrade() -> None:
    # 1. Add can_be_scheduled to master_role
    op.add_column(
        "master_role",
        sa.Column("can_be_scheduled", sa.Boolean(), nullable=False, server_default="false"),
    )

    # 2. Create jadwal_shift
    op.create_table(
        "jadwal_shift",
        sa.Column("id", sa.Integer(), primary_key=True),
        sa.Column("spbu_id", sa.Integer(), sa.ForeignKey("master_spbu.id", ondelete="CASCADE"), nullable=False, index=True),
        sa.Column("user_id", sa.Integer(), sa.ForeignKey("master_user.id", ondelete="CASCADE"), nullable=False),
        sa.Column("shift_id", sa.Integer(), sa.ForeignKey("master_spbu_shift.id", ondelete="CASCADE"), nullable=False),
        sa.Column("tanggal", sa.Date(), nullable=False),
        sa.Column("created_by_id", sa.Integer(), sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
        sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
        sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
        sa.UniqueConstraint("spbu_id", "user_id", "shift_id", "tanggal", name="uq_jadwal_shift"),
    )

    # 3. Create absensi
    op.create_table(
        "absensi",
        sa.Column("id", sa.Integer(), primary_key=True),
        sa.Column("spbu_id", sa.Integer(), sa.ForeignKey("master_spbu.id", ondelete="CASCADE"), nullable=False, index=True),
        sa.Column("shift_id", sa.Integer(), sa.ForeignKey("master_spbu_shift.id", ondelete="CASCADE"), nullable=False),
        sa.Column("tanggal", sa.Date(), nullable=False),
        sa.Column("foto_url", sa.String(500), nullable=True),
        sa.Column("uploaded_by_id", sa.Integer(), sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True),
        sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
        sa.Column("reviewed_by_id", sa.Integer(), sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True),
        sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
        sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
        sa.UniqueConstraint("spbu_id", "shift_id", "tanggal", name="uq_absensi"),
    )


def downgrade() -> None:
    op.drop_table("absensi")
    op.drop_table("jadwal_shift")
    op.drop_column("master_role", "can_be_scheduled")
```

- [ ] **Step 2: Run migration**

```bash
cd backend && source .venv/bin/activate
alembic upgrade head
```

Expected output includes:
```
Running upgrade w9a0b1c2d3e4 -> x0b1c2d3e4f5, Add General Affairs: can_be_scheduled, jadwal_shift, absensi.
```

- [ ] **Step 3: Commit**

```bash
git add backend/alembic/versions/x0b1c2d3e4f5_add_general_affairs.py
git commit -m "feat: migration — add General Affairs tables and can_be_scheduled flag"
```

---

## Task 2: Backend Models

**Files:**
- Modify: `backend/app/models/role.py`
- Create: `backend/app/models/general_affairs.py`

- [ ] **Step 1: Add 3 new values to ModulEnum and `can_be_scheduled` to Role in `role.py`**

In `backend/app/models/role.py`, add to `ModulEnum`:
```python
operators = "operators"
jadwal = "jadwal"
absensi = "absensi"
```

Add `can_be_scheduled` field to `Role` class, after `is_system`:
```python
can_be_scheduled: Mapped[bool] = mapped_column(Boolean, default=False)
```

- [ ] **Step 2: Create `backend/app/models/general_affairs.py`**

```python
"""General Affairs models — JadwalShift and Absensi."""

from datetime import date, datetime

from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.models.base import Base, TimestampMixin


class JadwalShift(Base, TimestampMixin):
    __tablename__ = "jadwal_shift"
    __table_args__ = (
        UniqueConstraint("spbu_id", "user_id", "shift_id", "tanggal", name="uq_jadwal_shift"),
    )

    id: Mapped[int] = mapped_column(primary_key=True)
    spbu_id: Mapped[int] = mapped_column(
        ForeignKey("master_spbu.id", ondelete="CASCADE"), index=True
    )
    user_id: Mapped[int] = mapped_column(
        ForeignKey("master_user.id", ondelete="CASCADE")
    )
    shift_id: Mapped[int] = mapped_column(
        ForeignKey("master_spbu_shift.id", ondelete="CASCADE")
    )
    tanggal: Mapped[date] = mapped_column(Date)
    created_by_id: Mapped[int | None] = mapped_column(
        ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True
    )
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

    spbu: Mapped["Spbu"] = relationship(foreign_keys=[spbu_id])
    user: Mapped["User"] = relationship(foreign_keys=[user_id])
    shift: Mapped["Shift"] = relationship()
    created_by: Mapped["User | None"] = relationship(foreign_keys=[created_by_id])


class Absensi(Base, TimestampMixin):
    __tablename__ = "absensi"
    __table_args__ = (
        UniqueConstraint("spbu_id", "shift_id", "tanggal", name="uq_absensi"),
    )

    id: Mapped[int] = mapped_column(primary_key=True)
    spbu_id: Mapped[int] = mapped_column(
        ForeignKey("master_spbu.id", ondelete="CASCADE"), index=True
    )
    shift_id: Mapped[int] = mapped_column(
        ForeignKey("master_spbu_shift.id", ondelete="CASCADE")
    )
    tanggal: Mapped[date] = mapped_column(Date)
    foto_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
    uploaded_by_id: Mapped[int | None] = mapped_column(
        ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True
    )
    uploaded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    status: Mapped[str] = mapped_column(String(20), default="pending")  # pending | approved
    reviewed_by_id: Mapped[int | None] = mapped_column(
        ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True
    )
    reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

    spbu: Mapped["Spbu"] = relationship(foreign_keys=[spbu_id])
    shift: Mapped["Shift"] = relationship()
    uploaded_by: Mapped["User | None"] = relationship(foreign_keys=[uploaded_by_id])
    reviewed_by: Mapped["User | None"] = relationship(foreign_keys=[reviewed_by_id])


from app.models.user import User  # noqa: E402, F401
from app.models.spbu import Spbu, Shift  # noqa: E402, F401
```

- [ ] **Step 3: Commit**

```bash
git add backend/app/models/role.py backend/app/models/general_affairs.py
git commit -m "feat: add General Affairs ORM models and extend ModulEnum"
```

---

## Task 3: Image Compression Utility

**Files:**
- Create: `backend/app/utils/image.py`
- Modify: `backend/requirements.txt`

- [ ] **Step 1: Add Pillow to requirements**

In `backend/requirements.txt`, add:
```
Pillow>=10.0.0
```

Install:
```bash
cd backend && source .venv/bin/activate && pip install Pillow
```

- [ ] **Step 2: Create `backend/app/utils/image.py`**

```python
"""Image compression utility — resize and compress before GDrive upload."""

import io


def compress_image(file_bytes: bytes, max_px: int = 1280, quality: int = 75) -> bytes:
    """
    Resize image to max_px on longest side (maintain aspect ratio) and
    compress to JPEG at given quality. Returns compressed JPEG bytes.

    Typical result: 100–300 KB vs original 3–8 MB.
    """
    from PIL import Image

    img = Image.open(io.BytesIO(file_bytes))

    # Convert to RGB (handles PNG with alpha, CMYK, etc.)
    if img.mode not in ("RGB", "L"):
        img = img.convert("RGB")

    # Resize only if larger than max_px
    w, h = img.size
    if max(w, h) > max_px:
        ratio = max_px / max(w, h)
        img = img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS)

    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=quality, optimize=True)
    return buf.getvalue()
```

- [ ] **Step 3: Commit**

```bash
git add backend/requirements.txt backend/app/utils/image.py
git commit -m "feat: add image compression utility using Pillow"
```

---

## Task 4: Backend Schemas

**Files:**
- Create: `backend/app/schemas/general_affairs.py`
- Modify: `backend/app/schemas/role.py`

- [ ] **Step 1: Create `backend/app/schemas/general_affairs.py`**

```python
"""Pydantic schemas for General Affairs module."""

from datetime import date, datetime

from pydantic import BaseModel, ConfigDict


# ── Operators ────────────────────────────────────────────────────────────────

class OperatorResponse(BaseModel):
    id: int
    nama: str
    posisi: str       # role nama
    is_active: bool


# ── Jadwal ───────────────────────────────────────────────────────────────────

class JadwalCreate(BaseModel):
    user_id: int
    shift_id: int
    tanggal: date


class JadwalBulkCreate(BaseModel):
    items: list[JadwalCreate]


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

    id: int
    spbu_id: int
    user_id: int
    shift_id: int
    tanggal: date
    user_nama: str | None = None
    shift_nama: str | None = None


# ── Absensi ──────────────────────────────────────────────────────────────────

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

    id: int
    spbu_id: int
    shift_id: int
    tanggal: date
    foto_url: str | None = None
    status: str
    uploaded_by_nama: str | None = None
    uploaded_at: datetime | None = None
    reviewed_by_nama: str | None = None
    reviewed_at: datetime | None = None
    shift_nama: str | None = None
```

- [ ] **Step 2: Extend `backend/app/schemas/role.py`**

Add `can_be_scheduled` to `RoleUpdate`:
```python
class RoleUpdate(BaseModel):
    nama: str | None = None
    deskripsi: str | None = None
    can_be_scheduled: bool | None = None
```

Add `can_be_scheduled` to `RoleResponse`:
```python
class RoleResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    spbu_id: int | None = None
    nama: str
    deskripsi: str | None = None
    is_system: bool
    can_be_scheduled: bool = False
    permissions: list[RolePermissionResponse] = []
```

- [ ] **Step 3: Commit**

```bash
git add backend/app/schemas/general_affairs.py backend/app/schemas/role.py
git commit -m "feat: add General Affairs schemas and extend RoleUpdate/RoleResponse"
```

---

## Task 5: Backend Repository

**Files:**
- Create: `backend/app/repositories/general_affairs_repository.py`

- [ ] **Step 1: Create `backend/app/repositories/general_affairs_repository.py`**

```python
"""General Affairs repository — operators, jadwal, absensi DB queries."""

from datetime import date, datetime, timezone

from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.models.general_affairs import Absensi, JadwalShift
from app.models.role import Role, UserSpbuAssignment
from app.models.user import User


# ── Operators ────────────────────────────────────────────────────────────────

async def get_operators(db: AsyncSession, spbu_id: int) -> list[dict]:
    """Return users assigned to spbu_id whose role has can_be_scheduled=True."""
    result = await db.execute(
        select(UserSpbuAssignment, User, Role)
        .join(User, UserSpbuAssignment.user_id == User.id)
        .join(Role, UserSpbuAssignment.role_id == Role.id)
        .where(
            UserSpbuAssignment.spbu_id == spbu_id,
            Role.can_be_scheduled.is_(True),
            User.deleted_at.is_(None),
        )
    )
    rows = result.all()
    return [
        {
            "id": user.id,
            "nama": user.name,
            "posisi": role.nama,
            "is_active": user.is_active,
        }
        for _, user, role in rows
    ]


# ── Jadwal ───────────────────────────────────────────────────────────────────

async def get_jadwal(
    db: AsyncSession, spbu_id: int, start: date, end: date
) -> list[JadwalShift]:
    result = await db.execute(
        select(JadwalShift)
        .options(selectinload(JadwalShift.user), selectinload(JadwalShift.shift))
        .where(
            JadwalShift.spbu_id == spbu_id,
            JadwalShift.tanggal >= start,
            JadwalShift.tanggal <= end,
            JadwalShift.deleted_at.is_(None),
        )
        .order_by(JadwalShift.tanggal, JadwalShift.user_id)
    )
    return list(result.scalars().all())


async def create_jadwal_bulk(
    db: AsyncSession,
    spbu_id: int,
    items: list[dict],
    created_by_id: int,
) -> list[JadwalShift]:
    """Insert multiple jadwal rows, skip duplicates (ON CONFLICT DO NOTHING via try/except)."""
    created = []
    for item in items:
        existing = await db.execute(
            select(JadwalShift).where(
                JadwalShift.spbu_id == spbu_id,
                JadwalShift.user_id == item["user_id"],
                JadwalShift.shift_id == item["shift_id"],
                JadwalShift.tanggal == item["tanggal"],
                JadwalShift.deleted_at.is_(None),
            )
        )
        if existing.scalar_one_or_none():
            continue
        j = JadwalShift(
            spbu_id=spbu_id,
            user_id=item["user_id"],
            shift_id=item["shift_id"],
            tanggal=item["tanggal"],
            created_by_id=created_by_id,
        )
        db.add(j)
        created.append(j)
    await db.commit()
    return created


async def soft_delete_jadwal(db: AsyncSession, jadwal_id: int, spbu_id: int) -> bool:
    result = await db.execute(
        select(JadwalShift).where(
            JadwalShift.id == jadwal_id,
            JadwalShift.spbu_id == spbu_id,
            JadwalShift.deleted_at.is_(None),
        )
    )
    j = result.scalar_one_or_none()
    if not j:
        return False
    j.deleted_at = datetime.now(timezone.utc)
    await db.commit()
    return True


# ── Absensi ──────────────────────────────────────────────────────────────────

async def get_absensi(
    db: AsyncSession, spbu_id: int, start: date, end: date
) -> list[Absensi]:
    result = await db.execute(
        select(Absensi)
        .options(
            selectinload(Absensi.shift),
            selectinload(Absensi.uploaded_by),
            selectinload(Absensi.reviewed_by),
        )
        .where(
            Absensi.spbu_id == spbu_id,
            Absensi.tanggal >= start,
            Absensi.tanggal <= end,
        )
        .order_by(Absensi.tanggal.desc(), Absensi.shift_id)
    )
    return list(result.scalars().all())


async def get_absensi_by_id(db: AsyncSession, absensi_id: int) -> Absensi | None:
    result = await db.execute(
        select(Absensi)
        .options(selectinload(Absensi.shift), selectinload(Absensi.uploaded_by), selectinload(Absensi.reviewed_by))
        .where(Absensi.id == absensi_id)
    )
    return result.scalar_one_or_none()


async def upsert_absensi(
    db: AsyncSession,
    spbu_id: int,
    shift_id: int,
    tanggal: date,
    foto_url: str,
    uploaded_by_id: int,
) -> Absensi:
    """Create or replace absensi record for a shift+date. Resets status to pending."""
    result = await db.execute(
        select(Absensi).where(
            Absensi.spbu_id == spbu_id,
            Absensi.shift_id == shift_id,
            Absensi.tanggal == tanggal,
        )
    )
    absensi = result.scalar_one_or_none()
    now = datetime.now(timezone.utc)
    if absensi:
        absensi.foto_url = foto_url
        absensi.uploaded_by_id = uploaded_by_id
        absensi.uploaded_at = now
        absensi.status = "pending"
        absensi.reviewed_by_id = None
        absensi.reviewed_at = None
    else:
        absensi = Absensi(
            spbu_id=spbu_id,
            shift_id=shift_id,
            tanggal=tanggal,
            foto_url=foto_url,
            uploaded_by_id=uploaded_by_id,
            uploaded_at=now,
            status="pending",
        )
        db.add(absensi)
    await db.commit()
    await db.refresh(absensi)
    return absensi


async def approve_absensi(
    db: AsyncSession, absensi_id: int, reviewed_by_id: int
) -> Absensi | None:
    absensi = await get_absensi_by_id(db, absensi_id)
    if not absensi:
        return None
    absensi.status = "approved"
    absensi.reviewed_by_id = reviewed_by_id
    absensi.reviewed_at = datetime.now(timezone.utc)
    await db.commit()
    await db.refresh(absensi)
    return absensi
```

- [ ] **Step 2: Commit**

```bash
git add backend/app/repositories/general_affairs_repository.py
git commit -m "feat: add General Affairs repository"
```

---

## Task 6: Backend Service

**Files:**
- Create: `backend/app/services/general_affairs_service.py`

- [ ] **Step 1: Create `backend/app/services/general_affairs_service.py`**

```python
"""General Affairs service — business logic for operators, jadwal, absensi."""

from datetime import date

from sqlalchemy.ext.asyncio import AsyncSession

from app.repositories import general_affairs_repository as repo
from app.repositories import role_repository, spbu_repository
from app.schemas.general_affairs import JadwalCreate
from app.utils.file_upload import save_upload
from app.utils.image import compress_image


async def list_operators(db: AsyncSession, spbu_id: int) -> list[dict]:
    return await repo.get_operators(db, spbu_id)


async def list_jadwal(db: AsyncSession, spbu_id: int, start: date, end: date):
    return await repo.get_jadwal(db, spbu_id, start, end)


async def create_jadwal_bulk(
    db: AsyncSession,
    spbu_id: int,
    items: list[JadwalCreate],
    created_by_id: int,
) -> list:
    # Validate each user is assigned to this SPBU with can_be_scheduled role
    for item in items:
        operators = await repo.get_operators(db, spbu_id)
        operator_ids = {op["id"] for op in operators}
        if item.user_id not in operator_ids:
            raise ValueError(
                f"User {item.user_id} tidak terdaftar sebagai operator di SPBU ini"
            )
    return await repo.create_jadwal_bulk(
        db,
        spbu_id,
        [{"user_id": i.user_id, "shift_id": i.shift_id, "tanggal": i.tanggal} for i in items],
        created_by_id,
    )


async def delete_jadwal(db: AsyncSession, jadwal_id: int, spbu_id: int) -> bool:
    return await repo.soft_delete_jadwal(db, jadwal_id, spbu_id)


async def list_absensi(db: AsyncSession, spbu_id: int, start: date, end: date):
    return await repo.get_absensi(db, spbu_id, start, end)


async def upload_absensi(
    db: AsyncSession,
    spbu_id: int,
    shift_id: int,
    tanggal: date,
    file_bytes: bytes,
    filename: str,
    uploaded_by_id: int,
) -> object:
    # Compress before upload
    compressed = compress_image(file_bytes)
    subfolder = f"absensi/{spbu_id}/{tanggal.strftime('%Y/%m')}"
    foto_url = await save_upload(compressed, filename, subfolder)
    return await repo.upsert_absensi(db, spbu_id, shift_id, tanggal, foto_url, uploaded_by_id)


async def approve_absensi(db: AsyncSession, absensi_id: int, reviewed_by_id: int):
    absensi = await repo.approve_absensi(db, absensi_id, reviewed_by_id)
    if not absensi:
        raise ValueError("Absensi tidak ditemukan")
    return absensi
```

- [ ] **Step 2: Commit**

```bash
git add backend/app/services/general_affairs_service.py
git commit -m "feat: add General Affairs service"
```

---

## Task 7: Backend Router + Register

**Files:**
- Create: `backend/app/routers/general_affairs.py`
- Modify: `backend/app/main.py`

- [ ] **Step 1: Create `backend/app/routers/general_affairs.py`**

```python
"""General Affairs router — operators, jadwal, absensi endpoints."""

from datetime import date

from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.schemas.general_affairs import (
    AbsensiResponse,
    JadwalBulkCreate,
    JadwalResponse,
    OperatorResponse,
)
from app.services import general_affairs_service

router = APIRouter()


# ── Operators ─────────────────────────────────────────────────────────────────

@router.get("/spbus/{spbu_id}/operators", response_model=list[OperatorResponse])
async def list_operators(
    spbu_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> list[OperatorResponse]:
    ops = await general_affairs_service.list_operators(db, spbu_id)
    return [OperatorResponse(**op) for op in ops]


# ── Jadwal ────────────────────────────────────────────────────────────────────

@router.get("/spbus/{spbu_id}/jadwal", response_model=list[JadwalResponse])
async def list_jadwal(
    spbu_id: int,
    start: date,
    end: date,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> list[JadwalResponse]:
    rows = await general_affairs_service.list_jadwal(db, spbu_id, start, end)
    return [
        JadwalResponse(
            id=r.id,
            spbu_id=r.spbu_id,
            user_id=r.user_id,
            shift_id=r.shift_id,
            tanggal=r.tanggal,
            user_nama=r.user.name if r.user else None,
            shift_nama=r.shift.nama if r.shift else None,
        )
        for r in rows
    ]


@router.post("/spbus/{spbu_id}/jadwal", status_code=status.HTTP_201_CREATED)
async def create_jadwal(
    spbu_id: int,
    data: JadwalBulkCreate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> dict:
    try:
        created = await general_affairs_service.create_jadwal_bulk(
            db, spbu_id, data.items, current_user.id
        )
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
    return {"message": f"{len(created)} jadwal berhasil dibuat"}


@router.delete("/spbus/{spbu_id}/jadwal/{jadwal_id}", status_code=status.HTTP_200_OK)
async def delete_jadwal(
    spbu_id: int,
    jadwal_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> dict:
    deleted = await general_affairs_service.delete_jadwal(db, jadwal_id, spbu_id)
    if not deleted:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Jadwal tidak ditemukan")
    return {"message": "Jadwal berhasil dihapus"}


# ── Absensi ───────────────────────────────────────────────────────────────────

@router.get("/spbus/{spbu_id}/absensi", response_model=list[AbsensiResponse])
async def list_absensi(
    spbu_id: int,
    start: date,
    end: date,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> list[AbsensiResponse]:
    rows = await general_affairs_service.list_absensi(db, spbu_id, start, end)
    return [
        AbsensiResponse(
            id=r.id,
            spbu_id=r.spbu_id,
            shift_id=r.shift_id,
            tanggal=r.tanggal,
            foto_url=r.foto_url,
            status=r.status,
            uploaded_by_nama=r.uploaded_by.name if r.uploaded_by else None,
            uploaded_at=r.uploaded_at,
            reviewed_by_nama=r.reviewed_by.name if r.reviewed_by else None,
            reviewed_at=r.reviewed_at,
            shift_nama=r.shift.nama if r.shift else None,
        )
        for r in rows
    ]


@router.post("/spbus/{spbu_id}/absensi", status_code=status.HTTP_201_CREATED)
async def upload_absensi(
    spbu_id: int,
    shift_id: int = Form(...),
    tanggal: date = Form(...),
    foto: UploadFile = File(...),
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> dict:
    file_bytes = await foto.read()
    if len(file_bytes) == 0:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File kosong")
    try:
        absensi = await general_affairs_service.upload_absensi(
            db, spbu_id, shift_id, tanggal,
            file_bytes, foto.filename or "absensi.jpg", current_user.id
        )
    except Exception as e:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
    return {"message": "Foto absensi berhasil diupload", "id": absensi.id, "foto_url": absensi.foto_url}


@router.patch("/spbus/{spbu_id}/absensi/{absensi_id}/approve")
async def approve_absensi(
    spbu_id: int,
    absensi_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
) -> dict:
    try:
        absensi = await general_affairs_service.approve_absensi(db, absensi_id, current_user.id)
    except ValueError as e:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
    return {"message": "Absensi diapprove", "id": absensi.id, "status": absensi.status}
```

- [ ] **Step 2: Register router in `backend/app/main.py`**

Add import at top with existing imports:
```python
from app.routers import general_affairs
```

Add after existing `app.include_router` calls:
```python
app.include_router(general_affairs.router, prefix="/api/v1", tags=["general-affairs"])
```

- [ ] **Step 3: Commit**

```bash
git add backend/app/routers/general_affairs.py backend/app/main.py
git commit -m "feat: add General Affairs router and register in main"
```

---

## Task 8: Update Seed Data

**Files:**
- Modify: `backend/app/utils/seed.py`

- [ ] **Step 1: Add GA permissions to permission matrices in seed.py**

In `SPBU_ADMIN_PERMISSIONS` list, add:
```python
(ModulEnum.operators, AksiEnum.view),
(ModulEnum.jadwal, AksiEnum.view),
(ModulEnum.jadwal, AksiEnum.create),
(ModulEnum.jadwal, AksiEnum.edit),
(ModulEnum.jadwal, AksiEnum.delete),
(ModulEnum.absensi, AksiEnum.view),
(ModulEnum.absensi, AksiEnum.create),
(ModulEnum.absensi, AksiEnum.approve),
```

In `MANAGER_PERMISSIONS` list, add:
```python
(ModulEnum.operators, AksiEnum.view),
(ModulEnum.jadwal, AksiEnum.view),
(ModulEnum.absensi, AksiEnum.view),
(ModulEnum.absensi, AksiEnum.approve),
```

In `OPERATOR_PERMISSIONS` list, add:
```python
(ModulEnum.operators, AksiEnum.view),
(ModulEnum.jadwal, AksiEnum.view),
(ModulEnum.absensi, AksiEnum.view),
(ModulEnum.absensi, AksiEnum.create),
```

In `VIEWER_PERMISSIONS` list, add:
```python
(ModulEnum.operators, AksiEnum.view),
(ModulEnum.jadwal, AksiEnum.view),
(ModulEnum.absensi, AksiEnum.view),
```

- [ ] **Step 2: Set `can_be_scheduled=True` for Operator role in seed**

Find the loop where roles are created. After the role is created/fetched, add logic to set `can_be_scheduled`:
```python
# Inside the loop that creates default roles:
for nama, deskripsi, perms in default_roles:
    # ... existing code to get or create role ...
    # After role is fetched/created, set can_be_scheduled:
    if nama == "Operator":
        role.can_be_scheduled = True
        await db.commit()
```

- [ ] **Step 3: Commit**

```bash
git add backend/app/utils/seed.py
git commit -m "feat: add GA permissions to seed data and set Operator can_be_scheduled"
```

---

## Task 9: Frontend Types + API Client + Hooks

**Files:**
- Modify: `frontend/src/types/index.ts`
- Create: `frontend/src/lib/api/general-affairs.ts`
- Create: `frontend/src/lib/hooks/useGeneralAffairs.ts`
- Modify: `frontend/src/lib/api/roles.ts`
- Modify: `frontend/src/lib/hooks/useRoles.ts`

- [ ] **Step 1: Update `frontend/src/types/index.ts`**

Update `ModulType` to include new modules:
```typescript
export type ModulType =
  | 'dashboard' | 'penjualan' | 'stock' | 'penebusan' | 'penerimaan'
  | 'expenses' | 'penyetoran' | 'rekonsiliasi' | 'laporan' | 'analytics'
  | 'anomali' | 'products' | 'tangki' | 'spbu_settings' | 'contracts'
  | 'users' | 'spbu_management'
  | 'operators' | 'jadwal' | 'absensi'
```

Update `Role` interface:
```typescript
export interface Role {
  id: number
  spbu_id?: number
  nama: string
  deskripsi?: string
  is_system: boolean
  can_be_scheduled: boolean
  permissions?: RolePermission[]
}
```

Add new interfaces at the bottom of the file:
```typescript
export interface Operator {
  id: number
  nama: string
  posisi: string
  is_active: boolean
}

export interface JadwalShift {
  id: number
  spbu_id: number
  user_id: number
  shift_id: number
  tanggal: string   // YYYY-MM-DD
  user_nama?: string
  shift_nama?: string
}

export interface Absensi {
  id: number
  spbu_id: number
  shift_id: number
  tanggal: string   // YYYY-MM-DD
  foto_url?: string
  status: 'pending' | 'approved'
  uploaded_by_nama?: string
  uploaded_at?: string
  reviewed_by_nama?: string
  reviewed_at?: string
  shift_nama?: string
}
```

- [ ] **Step 2: Create `frontend/src/lib/api/general-affairs.ts`**

```typescript
import apiClient from './client'
import type { Operator, JadwalShift, Absensi } from '@/types'

export const gaApi = {
  // Operators
  listOperators: (spbuId: number): Promise<Operator[]> =>
    apiClient.get<Operator[]>(`/spbus/${spbuId}/operators`).then((r) => r.data),

  // Jadwal
  listJadwal: (spbuId: number, start: string, end: string): Promise<JadwalShift[]> =>
    apiClient.get<JadwalShift[]>(`/spbus/${spbuId}/jadwal`, { params: { start, end } }).then((r) => r.data),

  createJadwalBulk: (spbuId: number, items: Array<{ user_id: number; shift_id: number; tanggal: string }>): Promise<{ message: string }> =>
    apiClient.post(`/spbus/${spbuId}/jadwal`, { items }).then((r) => r.data),

  deleteJadwal: (spbuId: number, jadwalId: number): Promise<{ message: string }> =>
    apiClient.delete(`/spbus/${spbuId}/jadwal/${jadwalId}`).then((r) => r.data),

  // Absensi
  listAbsensi: (spbuId: number, start: string, end: string): Promise<Absensi[]> =>
    apiClient.get<Absensi[]>(`/spbus/${spbuId}/absensi`, { params: { start, end } }).then((r) => r.data),

  uploadAbsensi: (spbuId: number, shiftId: number, tanggal: string, foto: File): Promise<{ message: string; id: number; foto_url: string }> => {
    const form = new FormData()
    form.append('shift_id', String(shiftId))
    form.append('tanggal', tanggal)
    form.append('foto', foto)
    return apiClient.post(`/spbus/${spbuId}/absensi`, form, {
      headers: { 'Content-Type': 'multipart/form-data' },
    }).then((r) => r.data)
  },

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

- [ ] **Step 3: Create `frontend/src/lib/hooks/useGeneralAffairs.ts`**

```typescript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { gaApi } from '@/lib/api/general-affairs'

export function useOperators(spbuId: number | undefined) {
  return useQuery({
    queryKey: ['operators', spbuId],
    queryFn: () => gaApi.listOperators(spbuId!),
    enabled: !!spbuId,
    staleTime: 60_000,
  })
}

export function useJadwal(spbuId: number | undefined, start: string, end: string) {
  return useQuery({
    queryKey: ['jadwal', spbuId, start, end],
    queryFn: () => gaApi.listJadwal(spbuId!, start, end),
    enabled: !!spbuId,
    staleTime: 30_000,
  })
}

export function useCreateJadwalBulk(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (items: Array<{ user_id: number; shift_id: number; tanggal: string }>) =>
      gaApi.createJadwalBulk(spbuId, items),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['jadwal', spbuId] }),
  })
}

export function useDeleteJadwal(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (jadwalId: number) => gaApi.deleteJadwal(spbuId, jadwalId),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['jadwal', spbuId] }),
  })
}

export function useAbsensi(spbuId: number | undefined, start: string, end: string) {
  return useQuery({
    queryKey: ['absensi', spbuId, start, end],
    queryFn: () => gaApi.listAbsensi(spbuId!, start, end),
    enabled: !!spbuId,
    staleTime: 30_000,
  })
}

export function useUploadAbsensi(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: ({ shiftId, tanggal, foto }: { shiftId: number; tanggal: string; foto: File }) =>
      gaApi.uploadAbsensi(spbuId, shiftId, tanggal, foto),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['absensi', spbuId] }),
  })
}

export function useApproveAbsensi(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (absensiId: number) => gaApi.approveAbsensi(spbuId, absensiId),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['absensi', spbuId] }),
  })
}
```

- [ ] **Step 4: Update `frontend/src/lib/api/roles.ts` — add `can_be_scheduled` to update**

```typescript
update: (id: number, data: { nama?: string; deskripsi?: string; can_be_scheduled?: boolean }) =>
  apiClient.patch<ApiResponse<Role>>(`/roles/${id}`, data),
```

- [ ] **Step 5: Add `useUpdateCanBeScheduled` to `frontend/src/lib/hooks/useRoles.ts`**

```typescript
export function useUpdateCanBeScheduled(id: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (can_be_scheduled: boolean) => rolesApi.update(id, { can_be_scheduled }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['roles'] }),
  })
}
```

- [ ] **Step 6: Commit**

```bash
git add frontend/src/types/index.ts frontend/src/lib/api/general-affairs.ts \
  frontend/src/lib/hooks/useGeneralAffairs.ts frontend/src/lib/api/roles.ts \
  frontend/src/lib/hooks/useRoles.ts
git commit -m "feat: add GA frontend types, API client, and hooks"
```

---

## Task 10: Sidebar Update

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

- [ ] **Step 1: Add General Affairs to sidebar**

In `sidebar.tsx`, add a new nav group constant after `NAV_MASTER`:
```typescript
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' },
]
```

In `getActiveNav()`, add:
```typescript
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'
```

In the sidebar JSX, add a new section after the Master Data section, following the same pattern as existing sections:
```tsx
{/* General Affairs */}
<div className="nav-item mt-1">
  <div className="nav-link text-muted small fw-bold py-1">
    <span>General Affairs</span>
  </div>
</div>
{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-block">
        <i className={`ti ${item.icon}`}></i>
      </span>
      <span className="nav-link-title">{item.label}</span>
    </Link>
  </li>
))}
```

- [ ] **Step 2: Commit**

```bash
git add frontend/src/components/ui/sidebar.tsx
git commit -m "feat: add General Affairs section to sidebar"
```

---

## Task 11: Roles UI — can_be_scheduled + new modules

**Files:**
- Modify: `frontend/src/app/(dashboard)/users/roles/roles-client.tsx`

- [ ] **Step 1: Read the roles-client.tsx file** to understand current structure before modifying.

- [ ] **Step 2: Add `can_be_scheduled` toggle**

Find where each role card is rendered. Add a toggle row below the role name/description, using `useUpdateCanBeScheduled`:

```tsx
import { useUpdateCanBeScheduled } from '@/lib/hooks/useRoles'

// Inside the role card, add:
function CanBeScheduledToggle({ role }: { role: Role }) {
  const update = useUpdateCanBeScheduled(role.id)
  return (
    <div className="d-flex align-items-center gap-2 mt-2">
      <label className="form-check form-switch mb-0">
        <input
          type="checkbox"
          className="form-check-input"
          checked={role.can_be_scheduled}
          disabled={update.isPending}
          onChange={(e) => update.mutate(e.target.checked)}
        />
        <span className="form-check-label small text-muted">Can be scheduled</span>
      </label>
    </div>
  )
}
```

Render `<CanBeScheduledToggle role={role} />` inside each role card.

- [ ] **Step 3: Add 3 new modules to the permission matrix checklist**

Find where the permission matrix is rendered (the list of modules × actions). Add `operators`, `jadwal`, `absensi` to the module list that is iterated over. The modules list likely looks like an array of `{ modul, label, aksi[] }`. Add:

```typescript
{ modul: 'operators', label: 'Operators', aksi: ['view'] },
{ modul: 'jadwal', label: 'Jadwal', aksi: ['view', 'create', 'edit', 'delete'] },
{ modul: 'absensi', label: 'Absensi', aksi: ['view', 'create', 'approve'] },
```

- [ ] **Step 4: Commit**

```bash
git add frontend/src/app/(dashboard)/users/roles/roles-client.tsx
git commit -m "feat: add can_be_scheduled toggle and GA modules to Roles UI"
```

---

## Task 12: Frontend — Operators Page

**Files:**
- Create: `frontend/src/app/(dashboard)/general-affairs/layout.tsx`
- Create: `frontend/src/app/(dashboard)/general-affairs/operators/page.tsx`
- Create: `frontend/src/app/(dashboard)/general-affairs/operators/operators-client.tsx`

- [ ] **Step 1: Create layout `frontend/src/app/(dashboard)/general-affairs/layout.tsx`**

```tsx
export default function GeneralAffairsLayout({ children }: { children: React.ReactNode }) {
  return <>{children}</>
}
```

- [ ] **Step 2: Create page `frontend/src/app/(dashboard)/general-affairs/operators/page.tsx`**

```tsx
import { OperatorsClient } from './operators-client'

export default function OperatorsPage() {
  return <OperatorsClient />
}
```

- [ ] **Step 3: Create `frontend/src/app/(dashboard)/general-affairs/operators/operators-client.tsx`**

```tsx
'use client'

import { useOperators } from '@/lib/hooks/useGeneralAffairs'
import { useSpbuStore } from '@/stores/spbu-store'
import { useMe } from '@/lib/hooks/useAuth'
import { useSPBUs } from '@/lib/hooks/useSPBUs'

export function OperatorsClient() {
  const { data: me } = useMe()
  const { activeSPBU } = useSpbuStore()
  const { data: spbus } = useSPBUs()
  const spbuId = activeSPBU?.id
  const { data: operators = [], isLoading } = useOperators(spbuId)

  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">Operators</h2>
              <div className="text-muted mt-1">
                {activeSPBU ? `${activeSPBU.name}` : 'Select a station'}
              </div>
            </div>
          </div>
        </div>
      </div>

      <div className="page-body">
        <div className="container-xl">
          {isLoading ? (
            <div className="text-center py-5">
              <div className="spinner-border text-primary"></div>
            </div>
          ) : (
            <div className="card">
              <div className="table-responsive">
                <table className="table table-vcenter card-table">
                  <thead>
                    <tr>
                      <th>Name</th>
                      <th>Position</th>
                      <th>Status</th>
                    </tr>
                  </thead>
                  <tbody>
                    {operators.length === 0 ? (
                      <tr>
                        <td colSpan={3} className="text-center text-muted py-4">
                          No operators found for this station
                        </td>
                      </tr>
                    ) : (
                      operators.map((op) => (
                        <tr key={op.id}>
                          <td className="fw-medium">{op.nama}</td>
                          <td>{op.posisi}</td>
                          <td>
                            <span className={`badge ${op.is_active ? 'bg-success-lt text-success' : 'bg-secondary-lt text-secondary'}`}>
                              {op.is_active ? 'Active' : 'Inactive'}
                            </span>
                          </td>
                        </tr>
                      ))
                    )}
                  </tbody>
                </table>
              </div>
            </div>
          )}
        </div>
      </div>
    </>
  )
}
```

- [ ] **Step 4: Commit**

```bash
git add frontend/src/app/(dashboard)/general-affairs/
git commit -m "feat: add Operators page"
```

---

## Task 13: Frontend — Jadwal Page (Schedule Grid)

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

- [ ] **Step 1: Create page `frontend/src/app/(dashboard)/general-affairs/jadwal/page.tsx`**

```tsx
import { JadwalClient } from './jadwal-client'

export default function JadwalPage() {
  return <JadwalClient />
}
```

- [ ] **Step 2: Create `frontend/src/app/(dashboard)/general-affairs/jadwal/jadwal-client.tsx`**

```tsx
'use client'

import { useState, useMemo } from 'react'
import { useJadwal, useCreateJadwalBulk, useDeleteJadwal, useOperators } from '@/lib/hooks/useGeneralAffairs'
import { useSpbuStore } from '@/stores/spbu-store'
import { useTangkis } from '@/lib/hooks/useSPBUs'
import { useSPBUs } from '@/lib/hooks/useSPBUs'

function getWeekDates(anchor: Date): Date[] {
  const monday = new Date(anchor)
  monday.setDate(anchor.getDate() - ((anchor.getDay() + 6) % 7))
  return Array.from({ length: 7 }, (_, i) => {
    const d = new Date(monday)
    d.setDate(monday.getDate() + i)
    return d
  })
}

function getMonthDates(year: number, month: number): Date[] {
  const days = new Date(year, month + 1, 0).getDate()
  return Array.from({ length: days }, (_, i) => new Date(year, month, i + 1))
}

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

function formatHeader(d: Date, mode: 'weekly' | 'monthly'): string {
  if (mode === 'weekly') {
    return d.toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric' })
  }
  return String(d.getDate())
}

// Simple shift picker dropdown shown in a cell
function ShiftCell({
  jadwalId,
  shiftNama,
  shifts,
  onAssign,
  onRemove,
}: {
  jadwalId?: number
  shiftNama?: string
  shifts: Array<{ id: number; nama: string }>
  onAssign: (shiftId: number) => void
  onRemove: () => void
}) {
  const [open, setOpen] = useState(false)

  if (jadwalId && shiftNama) {
    return (
      <div className="d-flex align-items-center gap-1" style={{ minWidth: 80 }}>
        <span className="badge bg-blue-lt text-blue small">{shiftNama}</span>
        <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>
    )
  }

  return (
    <div className="position-relative">
      <button
        className="btn btn-ghost-secondary btn-sm w-100"
        style={{ fontSize: '0.7rem', minWidth: 60 }}
        onClick={() => setOpen(!open)}
      >
        <i className="ti ti-plus"></i>
      </button>
      {open && (
        <div className="dropdown-menu show" style={{ minWidth: 120, zIndex: 10 }}>
          {shifts.map((s) => (
            <button
              key={s.id}
              className="dropdown-item"
              onClick={() => { onAssign(s.id); setOpen(false) }}
            >
              {s.nama}
            </button>
          ))}
        </div>
      )}
    </div>
  )
}

export function JadwalClient() {
  const { activeSPBU } = useSpbuStore()
  const spbuId = activeSPBU?.id

  const [mode, setMode] = useState<'weekly' | 'monthly'>('weekly')
  const [anchor, setAnchor] = useState(new Date())

  const dates = useMemo(() => {
    if (mode === 'weekly') return getWeekDates(anchor)
    return getMonthDates(anchor.getFullYear(), anchor.getMonth())
  }, [mode, anchor])

  const start = toISO(dates[0])
  const end = toISO(dates[dates.length - 1])

  const { data: operators = [], isLoading: loadingOps } = useOperators(spbuId)
  const { data: jadwalList = [], isLoading: loadingJadwal } = useJadwal(spbuId, start, end)
  const createBulk = useCreateJadwalBulk(spbuId!)
  const deleteJadwal = useDeleteJadwal(spbuId!)

  // Fetch shifts for this SPBU to show in picker
  const { data: spbus = [] } = useSPBUs()
  const currentSpbu = spbus.find((s) => s.id === spbuId)
  const shifts: Array<{ id: number; nama: string }> = (currentSpbu as any)?.shifts ?? []

  // Build lookup: operatorId+tanggal → jadwal entry
  const jadwalMap = useMemo(() => {
    const map = new Map<string, { id: number; shift_id: number; shift_nama: string }>()
    for (const j of jadwalList) {
      map.set(`${j.user_id}-${j.tanggal}`, { id: j.id, shift_id: j.shift_id, shift_nama: j.shift_nama ?? '' })
    }
    return map
  }, [jadwalList])

  const handleAssign = (userId: number, tanggal: string, shiftId: number) => {
    createBulk.mutate([{ user_id: userId, shift_id: shiftId, tanggal }])
  }

  const handleRemove = (jadwalId: number) => {
    deleteJadwal.mutate(jadwalId)
  }

  const isLoading = loadingOps || loadingJadwal

  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">Schedule</h2>
              <div className="text-muted mt-1">{activeSPBU?.name}</div>
            </div>
            <div className="col-auto d-flex gap-2">
              <div className="btn-group">
                <button
                  className={`btn btn-sm ${mode === 'weekly' ? 'btn-primary' : 'btn-ghost-secondary'}`}
                  onClick={() => setMode('weekly')}
                >Weekly</button>
                <button
                  className={`btn btn-sm ${mode === 'monthly' ? 'btn-primary' : 'btn-ghost-secondary'}`}
                  onClick={() => setMode('monthly')}
                >Monthly</button>
              </div>
              <button
                className="btn btn-sm btn-ghost-secondary"
                onClick={() => {
                  const d = new Date(anchor)
                  mode === 'weekly' ? d.setDate(d.getDate() - 7) : d.setMonth(d.getMonth() - 1)
                  setAnchor(d)
                }}
              ><i className="ti ti-chevron-left"></i></button>
              <span className="btn btn-sm btn-ghost-secondary" style={{ cursor: 'default', minWidth: 120, textAlign: 'center' }}>
                {mode === 'weekly'
                  ? `${dates[0].toLocaleDateString('id-ID', { day: 'numeric', month: 'short' })} – ${dates[6].toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' })}`
                  : anchor.toLocaleDateString('id-ID', { month: 'long', year: 'numeric' })}
              </span>
              <button
                className="btn btn-sm btn-ghost-secondary"
                onClick={() => {
                  const d = new Date(anchor)
                  mode === 'weekly' ? d.setDate(d.getDate() + 7) : d.setMonth(d.getMonth() + 1)
                  setAnchor(d)
                }}
              ><i className="ti ti-chevron-right"></i></button>
            </div>
          </div>
        </div>
      </div>

      <div className="page-body">
        <div className="container-xl">
          {isLoading ? (
            <div className="text-center py-5"><div className="spinner-border text-primary"></div></div>
          ) : (
            <div className="card">
              <div className="table-responsive" style={{ overflowX: 'auto' }}>
                <table className="table table-sm table-bordered table-vcenter mb-0" style={{ minWidth: 600 }}>
                  <thead className="sticky-top bg-white">
                    <tr>
                      <th style={{ minWidth: 140 }}>Operator</th>
                      {dates.map((d) => (
                        <th key={toISO(d)} className="text-center" style={{ minWidth: 80 }}>
                          {formatHeader(d, mode)}
                        </th>
                      ))}
                    </tr>
                  </thead>
                  <tbody>
                    {operators.length === 0 ? (
                      <tr>
                        <td colSpan={dates.length + 1} className="text-center text-muted py-4">
                          No operators found
                        </td>
                      </tr>
                    ) : operators.map((op) => (
                      <tr key={op.id}>
                        <td className="fw-medium">{op.nama}</td>
                        {dates.map((d) => {
                          const iso = toISO(d)
                          const entry = jadwalMap.get(`${op.id}-${iso}`)
                          return (
                            <td key={iso} className="text-center p-1">
                              <ShiftCell
                                jadwalId={entry?.id}
                                shiftNama={entry?.shift_nama}
                                shifts={shifts}
                                onAssign={(shiftId) => handleAssign(op.id, iso, shiftId)}
                                onRemove={() => entry && handleRemove(entry.id)}
                              />
                            </td>
                          )
                        })}
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          )}
        </div>
      </div>
    </>
  )
}
```

- [ ] **Step 3: Commit**

```bash
git add frontend/src/app/(dashboard)/general-affairs/jadwal/
git commit -m "feat: add Jadwal (Schedule) grid page"
```

---

## Task 14: Frontend — Absensi Page

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

- [ ] **Step 1: Create page `frontend/src/app/(dashboard)/general-affairs/absensi/page.tsx`**

```tsx
import { AbsensiClient } from './absensi-client'

export default function AbsensiPage() {
  return <AbsensiClient />
}
```

- [ ] **Step 2: Create `frontend/src/app/(dashboard)/general-affairs/absensi/absensi-client.tsx`**

```tsx
'use client'

import { useState, useRef } from 'react'
import { useAbsensi, useUploadAbsensi, useApproveAbsensi } from '@/lib/hooks/useGeneralAffairs'
import { useSpbuStore } from '@/stores/spbu-store'
import { formatDate } from '@/lib/utils/format'
import type { Absensi } from '@/types'

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

// Upload modal
function UploadModal({
  absensi,
  spbuId,
  onClose,
}: {
  absensi: Absensi
  spbuId: number
  onClose: () => void
}) {
  const upload = useUploadAbsensi(spbuId)
  const [preview, setPreview] = useState<string | null>(null)
  const [file, setFile] = useState<File | null>(null)
  const [error, setError] = useState<string | null>(null)
  const inputRef = useRef<HTMLInputElement>(null)

  const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const f = e.target.files?.[0]
    if (!f) return
    setFile(f)
    setPreview(URL.createObjectURL(f))
  }

  const handleSubmit = async () => {
    if (!file) return
    setError(null)
    try {
      await upload.mutateAsync({ shiftId: absensi.shift_id, tanggal: absensi.tanggal, foto: file })
      onClose()
    } catch {
      setError('Upload gagal. Coba lagi.')
    }
  }

  return (
    <div className="modal modal-blur show d-block" style={{ background: 'rgba(0,0,0,0.5)' }}>
      <div className="modal-dialog modal-sm modal-dialog-centered">
        <div className="modal-content">
          <div className="modal-header">
            <h5 className="modal-title">Upload Bukti Hadir</h5>
            <button className="btn-close" onClick={onClose}></button>
          </div>
          <div className="modal-body">
            <p className="text-muted small mb-2">
              Shift: <strong>{absensi.shift_nama}</strong> · {formatDate(absensi.tanggal)}
            </p>
            {error && <div className="alert alert-danger py-2 small">{error}</div>}
            <input ref={inputRef} type="file" accept="image/*" className="d-none" onChange={handleFile} />
            <button className="btn btn-outline-secondary w-100 mb-3" onClick={() => inputRef.current?.click()}>
              <i className="ti ti-camera me-1"></i>
              {file ? 'Change Photo' : 'Select Photo'}
            </button>
            {preview && (
              <img src={preview} alt="preview" className="img-fluid rounded" style={{ maxHeight: 200, objectFit: 'cover', width: '100%' }} />
            )}
          </div>
          <div className="modal-footer">
            <button className="btn btn-ghost-secondary" onClick={onClose}>Cancel</button>
            <button
              className="btn btn-primary"
              disabled={!file || upload.isPending}
              onClick={handleSubmit}
            >
              {upload.isPending ? <span className="spinner-border spinner-border-sm me-1"></span> : null}
              Upload
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

export function AbsensiClient() {
  const { activeSPBU } = useSpbuStore()
  const spbuId = activeSPBU?.id

  const today = new Date()
  const [start] = useState(toISO(new Date(today.getFullYear(), today.getMonth(), 1)))
  const [end] = useState(toISO(new Date(today.getFullYear(), today.getMonth() + 1, 0)))

  const { data: absensiList = [], isLoading } = useAbsensi(spbuId, start, end)
  const approve = useApproveAbsensi(spbuId!)

  const [uploadTarget, setUploadTarget] = useState<Absensi | null>(null)

  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">
          {isLoading ? (
            <div className="text-center py-5"><div className="spinner-border text-primary"></div></div>
          ) : (
            <div className="card">
              <div className="table-responsive">
                <table className="table table-vcenter card-table">
                  <thead>
                    <tr>
                      <th>Date</th>
                      <th>Shift</th>
                      <th>Photo</th>
                      <th>Uploaded By</th>
                      <th>Status</th>
                      <th>Reviewed By</th>
                      <th style={{ width: 140 }}></th>
                    </tr>
                  </thead>
                  <tbody>
                    {absensiList.length === 0 ? (
                      <tr>
                        <td colSpan={7} className="text-center text-muted py-4">No attendance records this month</td>
                      </tr>
                    ) : absensiList.map((a) => (
                      <tr key={a.id}>
                        <td>{formatDate(a.tanggal)}</td>
                        <td>{a.shift_nama ?? `#${a.shift_id}`}</td>
                        <td>
                          {a.foto_url ? (
                            <a href={a.foto_url} target="_blank" rel="noreferrer">
                              <img src={a.foto_url} alt="foto" style={{ height: 48, width: 64, objectFit: 'cover', borderRadius: 4 }} />
                            </a>
                          ) : <span className="text-muted">—</span>}
                        </td>
                        <td className="text-muted small">{a.uploaded_by_nama ?? '—'}</td>
                        <td>
                          <span className={`badge ${a.status === 'approved' ? 'bg-success' : 'bg-warning text-dark'}`}>
                            {a.status === 'approved' ? 'Approved' : 'Pending'}
                          </span>
                        </td>
                        <td className="text-muted small">{a.reviewed_by_nama ?? '—'}</td>
                        <td className="text-end">
                          <button
                            className="btn btn-sm btn-outline-secondary me-1"
                            onClick={() => setUploadTarget(a)}
                            title="Upload / replace foto"
                          >
                            <i className="ti ti-camera me-1"></i>Upload
                          </button>
                          {a.status === 'pending' && a.foto_url && (
                            <button
                              className="btn btn-sm btn-success"
                              disabled={approve.isPending}
                              onClick={() => approve.mutate(a.id)}
                            >
                              <i className="ti ti-check me-1"></i>Approve
                            </button>
                          )}
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          )}
        </div>
      </div>

      {uploadTarget && spbuId && (
        <UploadModal
          absensi={uploadTarget}
          spbuId={spbuId}
          onClose={() => setUploadTarget(null)}
        />
      )}
    </>
  )
}
```

- [ ] **Step 3: Commit**

```bash
git add frontend/src/app/(dashboard)/general-affairs/absensi/
git commit -m "feat: add Absensi (Attendance) page with upload and approve"
```

---

## Task 15: Final Verification

- [ ] **Step 1: Restart backend and verify all endpoints respond**

```bash
cd backend && source .venv/bin/activate
uvicorn app.main:app --reload --port 8006
```

Test endpoints:
```bash
# Get operators (requires auth token)
curl http://localhost:8006/api/v1/spbus/1/operators -H "Authorization: Bearer <token>"
# Expected: 200 with list (empty if no schedulable users assigned)

curl http://localhost:8006/api/v1/spbus/1/jadwal?start=2026-04-01&end=2026-04-30 -H "Authorization: Bearer <token>"
# Expected: 200 with empty list []

curl http://localhost:8006/api/v1/spbus/1/absensi?start=2026-04-01&end=2026-04-30 -H "Authorization: Bearer <token>"
# Expected: 200 with empty list []
```

- [ ] **Step 2: Verify API docs**

Open `http://localhost:8006/api/docs` and confirm these routes appear under `general-affairs` tag:
- `GET /api/v1/spbus/{spbu_id}/operators`
- `GET /api/v1/spbus/{spbu_id}/jadwal`
- `POST /api/v1/spbus/{spbu_id}/jadwal`
- `DELETE /api/v1/spbus/{spbu_id}/jadwal/{jadwal_id}`
- `GET /api/v1/spbus/{spbu_id}/absensi`
- `POST /api/v1/spbus/{spbu_id}/absensi`
- `PATCH /api/v1/spbus/{spbu_id}/absensi/{absensi_id}/approve`

- [ ] **Step 3: Verify frontend pages load**

```bash
cd frontend && npm run dev
```

Check:
- `http://localhost:8007/general-affairs/operators` — loads, shows table
- `http://localhost:8007/general-affairs/jadwal` — loads, shows grid
- `http://localhost:8007/general-affairs/absensi` — loads, shows table
- `http://localhost:8007/users/roles` — shows `can_be_scheduled` toggle and new modules

- [ ] **Step 4: Run seed to apply GA permissions to existing roles**

```bash
cd backend && source .venv/bin/activate
python -m app.utils.seed
```

- [ ] **Step 5: Final commit**

```bash
git add -A
git commit -m "feat: complete General Affairs module (operators, jadwal, absensi)"
```
