# Approval Flows + BAST PDF 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 approval flows (submit/recall/review/unlock) to Expenses and Penerimaan, fix the broken Penyetoran backend (model mismatch after migration e7h8i9j0k1l2), redesign Penyetoran frontend (per-shift + batch), and add BAST PDF generation to all modules with approve.

**Architecture:** Backend follows the `operational_service` pattern: status enum on model → service functions (submit/recall/review/unlock) → PATCH endpoints on router. Penyetoran is a special case — individual records are auto-created from laporan_shift, batch submission is the primary UX flow. BAST PDF uses `reportlab` and is a GET endpoint returning `application/pdf`.

**Tech Stack:** FastAPI, SQLAlchemy 2.x async, Alembic, Pydantic v2, reportlab (new dep), Next.js 16, TanStack Query, Tabler CSS

---

## File Map

### Backend — Modified
- `backend/pyproject.toml` — add `reportlab>=4.0`
- `backend/app/models/expenses.py` — add StatusExpense enum + 9 audit columns
- `backend/app/models/penerimaan.py` — add StatusPenerimaan enum + 8 audit columns
- `backend/app/repositories/penyetoran_repository.py` — rewrite to match new model (no `confirmed_by`)
- `backend/app/schemas/expenses.py` — add status + audit fields to `ExpenseResponse`
- `backend/app/schemas/penerimaan.py` — add status + audit fields to `PenerimaanResponse`
- `backend/app/schemas/penyetoran.py` — rewrite `PenyetoranResponse`; add batch schemas
- `backend/app/services/expense_service.py` — add `submit_expense`, `recall_expense`, `review_expense`, `unlock_expense`
- `backend/app/services/penerimaan_service.py` — add `submit_penerimaan`, `review_penerimaan`, `unlock_penerimaan`
- `backend/app/services/penyetoran_service.py` — rewrite `_build_response`, remove broken code, add batch logic
- `backend/app/routers/expenses.py` — add submit/recall/review/unlock + bast-pdf endpoints
- `backend/app/routers/penerimaan.py` — add submit/review/unlock + bast-pdf endpoints
- `backend/app/routers/penyetoran.py` — rewrite; add batch endpoints + bast-pdf

### Backend — Created
- `backend/alembic/versions/f0i1j2k3l4m5_expenses_approval_fields.py`
- `backend/alembic/versions/g1j2k3l4m5n6_penerimaan_approval_fields.py`
- `backend/app/utils/bast_pdf.py` — PDF generation utility

### Frontend — Modified
- `frontend/src/types/index.ts` — update `Penyetoran`, `StatusPenyetoran`; add `PenyetoranBatch`; add `Expense.status`; add `Penerimaan.status`
- `frontend/src/lib/api/penyetoran.ts` — rewrite for new model + batch endpoints
- `frontend/src/lib/api/expenses.ts` — add submit/recall/review/unlock + bast-pdf
- `frontend/src/lib/api/penerimaan.ts` — add submit/review/unlock + bast-pdf
- `frontend/src/lib/hooks/usePenyetoran.ts` — rewrite hooks
- `frontend/src/lib/hooks/useExpenses.ts` — add approval mutations
- `frontend/src/lib/hooks/usePenerimaan.ts` — add approval mutations
- `frontend/src/app/(dashboard)/penyetoran/penyetoran-client.tsx` — full redesign
- `frontend/src/app/(dashboard)/expenses/expenses-client.tsx` — add approval UI + BAST download
- `frontend/src/app/(dashboard)/penerimaan/penerimaan-client.tsx` — add approval UI + BAST download
- `frontend/src/app/(dashboard)/penjualan/penjualan-client.tsx` — add BAST download button
- `frontend/src/app/(dashboard)/stock/stock-client.tsx` — add BAST download button

---

## Task 1: Fix Penyetoran Repository + Schema (backend, prerequisite)

The repository references `Penyetoran.confirmed_by` which was removed in migration `e7h8i9j0k1l2`. This crashes on first query.

**Files:**
- Modify: `backend/app/repositories/penyetoran_repository.py`
- Modify: `backend/app/schemas/penyetoran.py`

- [ ] **Step 1: Rewrite penyetoran_repository.py**

```python
"""Penyetoran repository — per-shift model (post e7h8i9j0k1l2 migration)."""

from datetime import date

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

from app.models.penyetoran import Penyetoran, PenyetoranBatch, StatusPenyetoran, StatusPenyetoranBatch


def _eager_opts():
    return [
        selectinload(Penyetoran.created_by),
        selectinload(Penyetoran.laporan_shift),
        selectinload(Penyetoran.batch),
    ]


async def get_by_id(db: AsyncSession, id: int, spbu_id: int) -> Penyetoran | None:
    result = await db.execute(
        select(Penyetoran)
        .where(Penyetoran.id == id, Penyetoran.spbu_id == spbu_id)
        .options(*_eager_opts())
    )
    return result.scalar_one_or_none()


async def get_by_laporan_shift_id(db: AsyncSession, laporan_shift_id: int) -> Penyetoran | None:
    result = await db.execute(
        select(Penyetoran)
        .where(Penyetoran.laporan_shift_id == laporan_shift_id)
        .options(*_eager_opts())
    )
    return result.scalar_one_or_none()


async def get_by_tanggal(db: AsyncSession, spbu_id: int, tanggal: date) -> Penyetoran | None:
    """Keep for backward compat with penyetoran_service summary — returns first match."""
    result = await db.execute(
        select(Penyetoran)
        .where(Penyetoran.spbu_id == spbu_id, Penyetoran.tanggal == tanggal)
        .options(*_eager_opts())
        .order_by(Penyetoran.id)
        .limit(1)
    )
    return result.scalar_one_or_none()


async def get_all(
    db: AsyncSession,
    spbu_id: int,
    tanggal_from: date | None = None,
    tanggal_to: date | None = None,
    skip: int = 0,
    limit: int = 50,
) -> tuple[list[Penyetoran], int]:
    query = (
        select(Penyetoran)
        .where(Penyetoran.spbu_id == spbu_id)
        .options(*_eager_opts())
        .order_by(Penyetoran.tanggal.desc(), Penyetoran.id.desc())
    )
    if tanggal_from:
        query = query.where(Penyetoran.tanggal >= tanggal_from)
    if tanggal_to:
        query = query.where(Penyetoran.tanggal <= tanggal_to)
    count_q = await db.execute(select(func.count()).select_from(query.subquery()))
    total = count_q.scalar_one()
    result = await db.execute(query.offset(skip).limit(limit))
    return list(result.scalars().all()), total


async def update(db: AsyncSession, p: Penyetoran, data: dict) -> Penyetoran:
    for k, v in data.items():
        setattr(p, k, v)
    await db.flush()
    await db.refresh(p)
    return await get_by_id(db, p.id, p.spbu_id)  # type: ignore[return-value]


async def update_bukti(db: AsyncSession, p: Penyetoran, url: str) -> Penyetoran:
    p.bukti_url = url
    await db.flush()
    await db.refresh(p)
    return await get_by_id(db, p.id, p.spbu_id)  # type: ignore[return-value]


# ── Batch ──────────────────────────────────────────────────────────────────────

def _batch_eager_opts():
    return [
        selectinload(PenyetoranBatch.submitted_by),
        selectinload(PenyetoranBatch.reviewed_by),
        selectinload(PenyetoranBatch.items).options(*_eager_opts()),
    ]


async def get_batch_by_id(db: AsyncSession, id: int, spbu_id: int) -> PenyetoranBatch | None:
    result = await db.execute(
        select(PenyetoranBatch)
        .where(PenyetoranBatch.id == id, PenyetoranBatch.spbu_id == spbu_id)
        .options(*_batch_eager_opts())
    )
    return result.scalar_one_or_none()


async def get_all_batches(
    db: AsyncSession, spbu_id: int, skip: int = 0, limit: int = 20
) -> tuple[list[PenyetoranBatch], int]:
    query = (
        select(PenyetoranBatch)
        .where(PenyetoranBatch.spbu_id == spbu_id)
        .options(*_batch_eager_opts())
        .order_by(PenyetoranBatch.id.desc())
    )
    count_q = await db.execute(select(func.count()).select_from(query.subquery()))
    total = count_q.scalar_one()
    result = await db.execute(query.offset(skip).limit(limit))
    return list(result.scalars().all()), total


async def create_batch(db: AsyncSession, spbu_id: int, data: dict) -> PenyetoranBatch:
    batch = PenyetoranBatch(spbu_id=spbu_id, **data)
    db.add(batch)
    await db.flush()
    await db.refresh(batch)
    return await get_batch_by_id(db, batch.id, spbu_id)  # type: ignore[return-value]
```

- [ ] **Step 2: Rewrite schemas/penyetoran.py — PenyetoranResponse + batch schemas**

Replace entire file:

```python
"""Schemas for the penyetoran (per-shift cash deposit) module."""

from datetime import date, datetime
from decimal import Decimal
from typing import Literal, Optional

from pydantic import BaseModel, ConfigDict

from app.models.penyetoran import StatusPenyetoran, StatusPenyetoranBatch


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

    id: int
    spbu_id: int
    laporan_shift_id: int
    tanggal: date
    shift_nama: Optional[str] = None   # populated from laporan_shift.shift
    jumlah_kas: Decimal
    jumlah_non_kas: Decimal
    total_penjualan: Decimal
    catatan: Optional[str]
    bukti_url: Optional[str]
    status: str
    batch_id: Optional[int]
    created_by_name: Optional[str]
    created_at: datetime
    updated_at: datetime


class PenyetoranUpdate(BaseModel):
    catatan: Optional[str] = None


class PenyetoranBatchCreate(BaseModel):
    penyetoran_ids: list[int]
    tanggal_from: date
    tanggal_to: date
    catatan: Optional[str] = None


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

    id: int
    spbu_id: int
    tanggal_from: date
    tanggal_to: date
    total_amount: Decimal
    catatan: Optional[str]
    status: str
    submitted_by_name: Optional[str]
    submitted_at: Optional[datetime]
    reviewed_by_name: Optional[str]
    reviewed_at: Optional[datetime]
    catatan_review: Optional[str]
    items: list[PenyetoranResponse] = []
    created_at: datetime
    updated_at: datetime


class BatchReviewRequest(BaseModel):
    action: Literal["approve", "reject"]
    catatan: Optional[str] = None


# Keep DailySummary-related schemas unchanged (still used by summary endpoint)
class ShiftProductSummary(BaseModel):
    produk_id: int
    produk_nama: str
    produk_kode: str
    volume_digital: Decimal
    volume_manual: Decimal
    volume_final: Decimal
    nilai: Decimal
    harga_jual: Decimal


class ShiftSummary(BaseModel):
    laporan_shift_id: int
    shift_id: int
    shift_nama: str
    status: str
    products: list[ShiftProductSummary]
    total_volume_digital: Decimal
    total_volume_manual: Decimal
    total_volume_final: Decimal
    total_nilai: Decimal


class ProductTotal(BaseModel):
    produk_id: int
    produk_nama: str
    produk_kode: str
    volume_digital: Decimal
    volume_manual: Decimal
    volume_final: Decimal
    nilai: Decimal
    harga_jual: Decimal


class DailySummary(BaseModel):
    tanggal: date
    shifts: list[ShiftSummary]
    products_total: list[ProductTotal]
    grand_total_volume_digital: Decimal
    grand_total_volume_manual: Decimal
    grand_total_volume_final: Decimal
    grand_total_nilai: Decimal
    total_expenses: Decimal
    suggested_setor: Decimal
    existing_penyetoran_id: Optional[int] = None
    existing_penyetoran_status: Optional[str] = None
```

- [ ] **Step 3: Rewrite penyetoran_service.py — fix _build_response, add batch logic**

Replace `_build_response` and all broken service functions. Keep `get_daily_summary` and helper aggregation functions (they work fine). Add batch functions:

```python
# Replace the broken _build_response:
def _build_response(p: Penyetoran) -> PenyetoranResponse:
    shift_nama = None
    if p.laporan_shift and p.laporan_shift.shift:
        shift_nama = p.laporan_shift.shift.nama
    return PenyetoranResponse(
        id=p.id,
        spbu_id=p.spbu_id,
        laporan_shift_id=p.laporan_shift_id,
        tanggal=p.tanggal,
        shift_nama=shift_nama,
        jumlah_kas=p.jumlah_kas,
        jumlah_non_kas=p.jumlah_non_kas,
        total_penjualan=p.total_penjualan,
        catatan=p.catatan,
        bukti_url=p.bukti_url,
        status=p.status if isinstance(p.status, str) else p.status.value,
        batch_id=p.batch_id,
        created_by_name=p.created_by.name if p.created_by else None,
        created_at=p.created_at,
        updated_at=p.updated_at,
    )


def _build_batch_response(batch: PenyetoranBatch) -> PenyetoranBatchResponse:
    return PenyetoranBatchResponse(
        id=batch.id,
        spbu_id=batch.spbu_id,
        tanggal_from=batch.tanggal_from,
        tanggal_to=batch.tanggal_to,
        total_amount=batch.total_amount,
        catatan=batch.catatan,
        status=batch.status if isinstance(batch.status, str) else batch.status.value,
        submitted_by_name=batch.submitted_by.name if batch.submitted_by else None,
        submitted_at=batch.submitted_at,
        reviewed_by_name=batch.reviewed_by.name if batch.reviewed_by else None,
        reviewed_at=batch.reviewed_at,
        catatan_review=batch.catatan_review,
        items=[_build_response(p) for p in (batch.items or [])],
        created_at=batch.created_at,
        updated_at=batch.updated_at,
    )
```

Also replace `list_penyetoran` to accept date range params, remove `create_penyetoran` / `update_penyetoran` / `submit_penyetoran` / `confirm_penyetoran` (these are now handled via batch or auto-created). Add:

```python
async def list_penyetoran(
    db: AsyncSession,
    spbu_id: int,
    tanggal_from: date | None = None,
    tanggal_to: date | None = None,
    skip: int = 0,
    limit: int = 50,
) -> tuple[list[PenyetoranResponse], int]:
    rows, total = await penyetoran_repository.get_all(db, spbu_id, tanggal_from, tanggal_to, skip, limit)
    return [_build_response(p) for p in rows], total


async def update_penyetoran(
    db: AsyncSession, spbu_id: int, penyetoran_id: int, body: PenyetoranUpdate
) -> PenyetoranResponse:
    p = await penyetoran_repository.get_by_id(db, penyetoran_id, spbu_id)
    if p is None:
        raise ValueError("Penyetoran tidak ditemukan")
    if p.status != StatusPenyetoran.DRAFT:
        raise ValueError("Hanya penyetoran DRAFT yang bisa diubah")
    data = {k: v for k, v in body.model_dump().items() if v is not None}
    try:
        p = await penyetoran_repository.update(db, p, data)
        await db.commit()
    except SQLAlchemyError:
        await db.rollback()
        raise
    return _build_response(p)


async def upload_bukti(
    db: AsyncSession, spbu_id: int, penyetoran_id: int, file_bytes: bytes, filename: str
) -> PenyetoranResponse:
    p = await penyetoran_repository.get_by_id(db, penyetoran_id, spbu_id)
    if p is None:
        raise ValueError("Penyetoran tidak ditemukan")
    if p.status != StatusPenyetoran.DRAFT:
        raise ValueError("Bukti hanya bisa diupload pada penyetoran DRAFT")
    ctx = UploadContext(str(spbu_id), "penyetoran", p.tanggal)
    url = await save_upload(file_bytes, filename, ctx)
    try:
        p = await penyetoran_repository.update_bukti(db, p, url)
        await db.commit()
    except SQLAlchemyError:
        await db.rollback()
        raise
    return _build_response(p)


async def list_batches(
    db: AsyncSession, spbu_id: int, skip: int = 0, limit: int = 20
) -> tuple[list[PenyetoranBatchResponse], int]:
    rows, total = await penyetoran_repository.get_all_batches(db, spbu_id, skip, limit)
    return [_build_batch_response(b) for b in rows], total


async def get_batch_detail(
    db: AsyncSession, spbu_id: int, batch_id: int
) -> PenyetoranBatchResponse:
    b = await penyetoran_repository.get_batch_by_id(db, batch_id, spbu_id)
    if b is None:
        raise ValueError("Batch tidak ditemukan")
    return _build_batch_response(b)


async def create_batch(
    db: AsyncSession, spbu_id: int, user_id: int, body: PenyetoranBatchCreate
) -> PenyetoranBatchResponse:
    from sqlalchemy import select as sa_select
    from app.models.penyetoran import PenyetoranBatch
    # Validate all penyetoran belong to this spbu and are DRAFT
    rows_result = await db.execute(
        sa_select(Penyetoran).where(
            Penyetoran.id.in_(body.penyetoran_ids),
            Penyetoran.spbu_id == spbu_id,
        )
    )
    rows = list(rows_result.scalars().all())
    if len(rows) != len(body.penyetoran_ids):
        raise ValueError("Satu atau lebih penyetoran tidak ditemukan")
    non_draft = [p.id for p in rows if p.status != StatusPenyetoran.DRAFT]
    if non_draft:
        raise ValueError(f"Penyetoran berikut bukan DRAFT: {non_draft}")

    total_amount = sum(p.total_penjualan for p in rows)
    try:
        batch = await penyetoran_repository.create_batch(db, spbu_id, {
            "tanggal_from": body.tanggal_from,
            "tanggal_to": body.tanggal_to,
            "total_amount": total_amount,
            "catatan": body.catatan,
            "status": StatusPenyetoranBatch.DRAFT,
            "submitted_by_id": user_id,
            "submitted_at": datetime.now(timezone.utc),
        })
        # Link all penyetoran to this batch and set status to SUBMITTED
        for p in rows:
            p.batch_id = batch.id
            p.status = StatusPenyetoran.SUBMITTED
        await db.commit()
        await db.refresh(batch)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return await get_batch_detail(db, spbu_id, batch.id)


async def submit_batch(
    db: AsyncSession, spbu_id: int, batch_id: int, user_id: int
) -> PenyetoranBatchResponse:
    batch = await penyetoran_repository.get_batch_by_id(db, batch_id, spbu_id)
    if batch is None:
        raise ValueError("Batch tidak ditemukan")
    if batch.status != StatusPenyetoranBatch.DRAFT:
        raise ValueError("Hanya batch DRAFT yang bisa di-submit")
    try:
        batch.status = StatusPenyetoranBatch.SUBMITTED
        batch.submitted_by_id = user_id
        batch.submitted_at = datetime.now(timezone.utc)
        await db.commit()
        await db.refresh(batch)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return _build_batch_response(batch)


async def review_batch(
    db: AsyncSession, spbu_id: int, batch_id: int, user_id: int,
    action: str, catatan: str | None
) -> PenyetoranBatchResponse:
    batch = await penyetoran_repository.get_batch_by_id(db, batch_id, spbu_id)
    if batch is None:
        raise ValueError("Batch tidak ditemukan")
    if batch.status != StatusPenyetoranBatch.SUBMITTED:
        raise ValueError("Hanya batch SUBMITTED yang bisa di-review")
    if action == "approve":
        try:
            batch.status = StatusPenyetoranBatch.APPROVED
            batch.reviewed_by_id = user_id
            batch.reviewed_at = datetime.now(timezone.utc)
            batch.catatan_review = catatan
            # Approve all items
            for p in (batch.items or []):
                p.status = StatusPenyetoran.APPROVED
            await db.commit()
            await db.refresh(batch)
        except SQLAlchemyError:
            await db.rollback()
            raise
    else:
        raise ValueError("action harus 'approve'")
    return _build_batch_response(batch)
```

- [ ] **Step 4: Rewrite penyetoran router**

Replace entire `backend/app/routers/penyetoran.py`:

```python
"""Router for penyetoran (per-shift cash deposit) endpoints."""

from datetime import date

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

from app.core.database import get_db
from app.dependencies import get_spbu_access
from app.models.user import User
from app.schemas.penyetoran import (
    BatchReviewRequest,
    PenyetoranBatchCreate,
    PenyetoranUpdate,
)
from app.services import penyetoran_service

router = APIRouter(prefix="/spbus/{spbu_id}/penyetoran", tags=["penyetoran"])


def _err(e: Exception) -> HTTPException:
    if isinstance(e, PermissionError):
        return HTTPException(status_code=403, detail=str(e))
    return HTTPException(status_code=400, detail=str(e))


@router.get("", response_model=dict)
async def list_penyetoran(
    spbu_id: int,
    tanggal_from: date | None = Query(default=None),
    tanggal_to: date | None = Query(default=None),
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=50, ge=1, le=200),
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    rows, total = await penyetoran_service.list_penyetoran(db, spbu_id, tanggal_from, tanggal_to, skip, limit)
    return {"data": [r.model_dump() for r in rows], "meta": {"total": total}}


@router.get("/summary/{tanggal}", response_model=dict)
async def get_daily_summary(
    spbu_id: int, tanggal: date,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    try:
        summary = await penyetoran_service.get_daily_summary(db, spbu_id, tanggal)
    except Exception as e:
        raise _err(e)
    return {"data": summary.model_dump()}


@router.get("/batches", response_model=dict)
async def list_batches(
    spbu_id: int,
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=20, ge=1, le=100),
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    rows, total = await penyetoran_service.list_batches(db, spbu_id, skip, limit)
    return {"data": [r.model_dump() for r in rows], "meta": {"total": total}}


@router.post("/batches", response_model=dict, status_code=201)
async def create_batch(
    spbu_id: int, body: PenyetoranBatchCreate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    try:
        batch = await penyetoran_service.create_batch(db, spbu_id, current_user.id, body)
    except Exception as e:
        raise _err(e)
    return {"data": batch.model_dump(), "message": "Batch berhasil dibuat"}


@router.get("/batches/{batch_id}", response_model=dict)
async def get_batch(
    spbu_id: int, batch_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    try:
        batch = await penyetoran_service.get_batch_detail(db, spbu_id, batch_id)
    except ValueError as e:
        raise HTTPException(404, detail=str(e))
    return {"data": batch.model_dump()}


@router.post("/batches/{batch_id}/submit", response_model=dict)
async def submit_batch(
    spbu_id: int, batch_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    try:
        batch = await penyetoran_service.submit_batch(db, spbu_id, batch_id, current_user.id)
    except Exception as e:
        raise _err(e)
    return {"data": batch.model_dump(), "message": "Batch berhasil di-submit"}


@router.post("/batches/{batch_id}/review", response_model=dict)
async def review_batch(
    spbu_id: int, batch_id: int, body: BatchReviewRequest,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    try:
        batch = await penyetoran_service.review_batch(db, spbu_id, batch_id, current_user.id, body.action, body.catatan)
    except Exception as e:
        raise _err(e)
    return {"data": batch.model_dump(), "message": "Batch berhasil di-approve"}


@router.get("/{penyetoran_id}", response_model=dict)
async def get_penyetoran(
    spbu_id: int, penyetoran_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    try:
        p = await penyetoran_service.get_penyetoran_detail(db, spbu_id, penyetoran_id)
    except ValueError as e:
        raise HTTPException(404, detail=str(e))
    return {"data": p.model_dump()}


@router.patch("/{penyetoran_id}", response_model=dict)
async def update_penyetoran(
    spbu_id: int, penyetoran_id: int, body: PenyetoranUpdate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    try:
        p = await penyetoran_service.update_penyetoran(db, spbu_id, penyetoran_id, body)
    except Exception as e:
        raise _err(e)
    return {"data": p.model_dump(), "message": "Penyetoran berhasil diupdate"}


@router.post("/{penyetoran_id}/bukti", response_model=dict, status_code=201)
async def upload_bukti(
    spbu_id: int, penyetoran_id: int,
    file: UploadFile = File(...),
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
) -> dict:
    file_bytes = await file.read()
    try:
        p = await penyetoran_service.upload_bukti(db, spbu_id, penyetoran_id, file_bytes, file.filename or "bukti")
    except Exception as e:
        raise _err(e)
    return {"data": p.model_dump(), "message": "Bukti berhasil diupload"}
```

- [ ] **Step 5: Add missing service imports and `get_penyetoran_detail` to penyetoran_service**

In `penyetoran_service.py`, ensure these imports are present and `get_penyetoran_detail` is defined:

```python
from datetime import date, datetime, timezone
from sqlalchemy.exc import SQLAlchemyError
from app.models.penyetoran import Penyetoran, PenyetoranBatch, StatusPenyetoran, StatusPenyetoranBatch
from app.repositories import penyetoran_repository
from app.schemas.penyetoran import (
    DailySummary, PenyetoranBatchCreate, PenyetoranBatchResponse,
    PenyetoranResponse, PenyetoranUpdate,
    ProductTotal, ShiftProductSummary, ShiftSummary,
)
from app.utils.file_upload import UploadContext, save_upload

async def get_penyetoran_detail(db, spbu_id: int, penyetoran_id: int) -> PenyetoranResponse:
    p = await penyetoran_repository.get_by_id(db, penyetoran_id, spbu_id)
    if p is None:
        raise ValueError("Penyetoran tidak ditemukan")
    return _build_response(p)
```

- [ ] **Step 6: Test backend restarts without errors**

```bash
cd /Users/dhardhirdhor/Sites/spbu.com/backend
python -c "from app.services import penyetoran_service; print('OK')"
python -c "from app.repositories import penyetoran_repository; print('OK')"
```

Expected: `OK` both times, no AttributeError.

- [ ] **Step 7: Commit**

```bash
cd /Users/dhardhirdhor/Sites/spbu.com
git add backend/app/repositories/penyetoran_repository.py \
        backend/app/schemas/penyetoran.py \
        backend/app/services/penyetoran_service.py \
        backend/app/routers/penyetoran.py
git commit -m "fix(penyetoran): rewrite service/schema/repo to match per-shift model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 2: Expenses Approval — Migration + Model

**Files:**
- Modify: `backend/app/models/expenses.py`
- Create: `backend/alembic/versions/f0i1j2k3l4m5_expenses_approval_fields.py`

- [ ] **Step 1: Find current alembic head**

```bash
cd /Users/dhardhirdhor/Sites/spbu.com/backend
alembic heads
```

Note the head revision ID (should be `e7h8i9j0k1l2` or check output).

- [ ] **Step 2: Add StatusExpense enum + audit columns to expenses model**

In `backend/app/models/expenses.py`, add after imports:

```python
import enum

class StatusExpense(str, enum.Enum):
    DRAFT = "draft"
    SUBMITTED = "submitted"
    APPROVED = "approved"
    REJECTED = "rejected"
```

Add these columns to the `Expense` class (after `bukti_url`):

```python
    status: Mapped[str] = mapped_column(String(20), nullable=False, default=StatusExpense.DRAFT)

    submitted_by_id: Mapped[int | None] = mapped_column(
        ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True
    )
    submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    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)
    catatan_review: Mapped[str | None] = mapped_column(Text, nullable=True)
    recalled_by_id: Mapped[int | None] = mapped_column(
        ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True
    )
    recalled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    unlocked_by_id: Mapped[int | None] = mapped_column(
        ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True
    )
    unlocked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    unlock_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
```

Add relationships (after `created_by`):

```python
    submitted_by: Mapped["User | None"] = relationship(foreign_keys=[submitted_by_id])
    reviewed_by: Mapped["User | None"] = relationship(foreign_keys=[reviewed_by_id])
    recalled_by: Mapped["User | None"] = relationship(foreign_keys=[recalled_by_id])
    unlocked_by: Mapped["User | None"] = relationship(foreign_keys=[unlocked_by_id])
```

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

Create `backend/alembic/versions/f0i1j2k3l4m5_expenses_approval_fields.py`:

```python
"""Add approval fields to expenses table.

Revision ID: f0i1j2k3l4m5
Revises: e7h8i9j0k1l2
Create Date: 2026-04-12
"""
from alembic import op
import sqlalchemy as sa

revision = "f0i1j2k3l4m5"
down_revision = "e7h8i9j0k1l2"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.add_column("expenses", sa.Column("status", sa.String(20), nullable=False, server_default="draft"))
    op.add_column("expenses", sa.Column("submitted_by_id", sa.Integer, sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True))
    op.add_column("expenses", sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True))
    op.add_column("expenses", sa.Column("reviewed_by_id", sa.Integer, sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True))
    op.add_column("expenses", sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
    op.add_column("expenses", sa.Column("catatan_review", sa.Text, nullable=True))
    op.add_column("expenses", sa.Column("recalled_by_id", sa.Integer, sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True))
    op.add_column("expenses", sa.Column("recalled_at", sa.DateTime(timezone=True), nullable=True))
    op.add_column("expenses", sa.Column("unlocked_by_id", sa.Integer, sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True))
    op.add_column("expenses", sa.Column("unlocked_at", sa.DateTime(timezone=True), nullable=True))
    op.add_column("expenses", sa.Column("unlock_reason", sa.Text, nullable=True))


def downgrade() -> None:
    for col in ["unlock_reason", "unlocked_at", "unlocked_by_id",
                "recalled_at", "recalled_by_id", "catatan_review",
                "reviewed_at", "reviewed_by_id", "submitted_at",
                "submitted_by_id", "status"]:
        op.drop_column("expenses", col)
```

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

```bash
cd /Users/dhardhirdhor/Sites/spbu.com/backend
alembic upgrade head
```

Expected: migration applies without error.

- [ ] **Step 5: Commit**

```bash
git add backend/app/models/expenses.py backend/alembic/versions/f0i1j2k3l4m5_expenses_approval_fields.py
git commit -m "feat(expenses): add status + approval audit columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 3: Expenses Approval — Schema + Service + Router

**Files:**
- Modify: `backend/app/schemas/expenses.py`
- Modify: `backend/app/services/expense_service.py`
- Modify: `backend/app/repositories/expense_repository.py`
- Modify: `backend/app/routers/expenses.py`

- [ ] **Step 1: Update ExpenseResponse schema**

In `backend/app/schemas/expenses.py`, add to `ExpenseResponse`:

```python
from datetime import date, datetime
from typing import Optional

class ExpenseResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    spbu_id: int
    laporan_shift_id: Optional[int]
    tanggal: date
    kategori_id: int
    kategori_nama: str
    keterangan: Optional[str]
    jumlah: Decimal
    bukti_url: Optional[str]
    status: str = "draft"
    created_by_name: Optional[str]
    submitted_by_name: Optional[str] = None
    submitted_at: Optional[datetime] = None
    reviewed_by_name: Optional[str] = None
    reviewed_at: Optional[datetime] = None
    catatan_review: Optional[str] = None
    recalled_by_name: Optional[str] = None
    recalled_at: Optional[datetime] = None
    unlocked_by_name: Optional[str] = None
    unlocked_at: Optional[datetime] = None
    unlock_reason: Optional[str] = None
    created_at: datetime


class ReviewRequest(BaseModel):
    action: Literal["approve", "reject"]
    catatan: Optional[str] = None


class UnlockRequest(BaseModel):
    alasan: str
```

Add `from typing import Literal, Optional` and `from decimal import Decimal` at the top.

- [ ] **Step 2: Update expense_repository to load approval relationships**

In `backend/app/repositories/expense_repository.py`, update `get_expense_by_id` and `get_all_expenses` selectinload options:

```python
from app.models.expenses import Expense, ExpenseKategori

# In get_expense_by_id and get_all_expenses, change options to:
.options(
    selectinload(Expense.kategori),
    selectinload(Expense.created_by),
    selectinload(Expense.submitted_by),
    selectinload(Expense.reviewed_by),
    selectinload(Expense.recalled_by),
    selectinload(Expense.unlocked_by),
)
```

- [ ] **Step 3: Update _build_response in expense_service.py**

```python
def _build_response(expense) -> ExpenseResponse:
    return ExpenseResponse(
        id=expense.id,
        spbu_id=expense.spbu_id,
        laporan_shift_id=expense.laporan_shift_id,
        tanggal=expense.tanggal,
        kategori_id=expense.kategori_id,
        kategori_nama=expense.kategori.nama if expense.kategori else "",
        keterangan=expense.keterangan,
        jumlah=expense.jumlah,
        bukti_url=expense.bukti_url,
        status=expense.status if isinstance(expense.status, str) else expense.status.value,
        created_by_name=expense.created_by.name if expense.created_by else None,
        submitted_by_name=expense.submitted_by.name if expense.submitted_by else None,
        submitted_at=expense.submitted_at,
        reviewed_by_name=expense.reviewed_by.name if expense.reviewed_by else None,
        reviewed_at=expense.reviewed_at,
        catatan_review=expense.catatan_review,
        recalled_by_name=expense.recalled_by.name if expense.recalled_by else None,
        recalled_at=expense.recalled_at,
        unlocked_by_name=expense.unlocked_by.name if expense.unlocked_by else None,
        unlocked_at=expense.unlocked_at,
        unlock_reason=expense.unlock_reason,
        created_at=expense.created_at,
    )
```

- [ ] **Step 4: Add approval service functions to expense_service.py**

Add these imports at top:

```python
from datetime import datetime, timezone
from app.models.expenses import StatusExpense
from app.models.role import AksiEnum, ModulEnum
from app.repositories import role_repository
```

Add these functions:

```python
async def _user_can_approve(db, user, spbu_id: int) -> bool:
    if user.is_superadmin:
        return True
    assignment = next((a for a in (user.assignments or []) if a.spbu_id == spbu_id), None)
    if assignment is None:
        return False
    return await role_repository.has_permission(db, assignment.role_id, ModulEnum.expenses, AksiEnum.approve)


async def submit_expense(db, spbu_id: int, expense_id: int, user_id: int) -> ExpenseResponse:
    expense = await expense_repository.get_expense_by_id(db, expense_id, spbu_id)
    if expense is None:
        raise ValueError("Expense tidak ditemukan")
    if expense.status != StatusExpense.DRAFT:
        raise ValueError("Hanya expense DRAFT yang bisa di-submit")
    try:
        expense.status = StatusExpense.SUBMITTED
        expense.submitted_by_id = user_id
        expense.submitted_at = datetime.now(timezone.utc)
        await db.commit()
        await db.refresh(expense)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return _build_response(await expense_repository.get_expense_by_id(db, expense_id, spbu_id))


async def recall_expense(db, spbu_id: int, expense_id: int, user_id: int) -> ExpenseResponse:
    expense = await expense_repository.get_expense_by_id(db, expense_id, spbu_id)
    if expense is None:
        raise ValueError("Expense tidak ditemukan")
    if expense.status != StatusExpense.SUBMITTED:
        raise ValueError("Recall hanya bisa pada expense SUBMITTED")
    try:
        expense.status = StatusExpense.DRAFT
        expense.recalled_by_id = user_id
        expense.recalled_at = datetime.now(timezone.utc)
        await db.commit()
        await db.refresh(expense)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return _build_response(await expense_repository.get_expense_by_id(db, expense_id, spbu_id))


async def review_expense(
    db, spbu_id: int, expense_id: int, user_id: int, action: str, catatan: str | None
) -> ExpenseResponse:
    expense = await expense_repository.get_expense_by_id(db, expense_id, spbu_id)
    if expense is None:
        raise ValueError("Expense tidak ditemukan")
    if expense.status != StatusExpense.SUBMITTED:
        raise ValueError("Hanya expense SUBMITTED yang bisa di-review")
    new_status = StatusExpense.APPROVED if action == "approve" else StatusExpense.REJECTED
    try:
        expense.status = new_status
        expense.reviewed_by_id = user_id
        expense.reviewed_at = datetime.now(timezone.utc)
        expense.catatan_review = catatan
        await db.commit()
        await db.refresh(expense)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return _build_response(await expense_repository.get_expense_by_id(db, expense_id, spbu_id))


async def unlock_expense(
    db, spbu_id: int, expense_id: int, user_id: int, alasan: str
) -> ExpenseResponse:
    expense = await expense_repository.get_expense_by_id(db, expense_id, spbu_id)
    if expense is None:
        raise ValueError("Expense tidak ditemukan")
    if expense.status != StatusExpense.APPROVED:
        raise ValueError("Hanya expense APPROVED yang bisa di-unlock")
    if not alasan:
        raise ValueError("Alasan unlock wajib diisi")
    try:
        expense.status = StatusExpense.DRAFT
        expense.unlocked_by_id = user_id
        expense.unlocked_at = datetime.now(timezone.utc)
        expense.unlock_reason = alasan
        await db.commit()
        await db.refresh(expense)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return _build_response(await expense_repository.get_expense_by_id(db, expense_id, spbu_id))
```

Also update `update_expense` and `delete_expense` to enforce locked state:

```python
# In update_expense, add before the update logic:
    if expense.status == StatusExpense.APPROVED:
        raise ValueError("Expense yang sudah Approved tidak bisa diedit. Unlock terlebih dahulu.")

# In delete_expense, add:
    if expense.status != StatusExpense.DRAFT:
        raise ValueError("Hanya expense DRAFT yang bisa dihapus")
```

- [ ] **Step 5: Add approval endpoints to expenses router**

In `backend/app/routers/expenses.py`, add these imports:

```python
from app.schemas.expenses import ExpenseCreate, ExpenseUpdate, KategoriCreate, ReviewRequest, UnlockRequest
```

Add these endpoints after the delete endpoint:

```python
@router.post("/{expense_id}/submit", response_model=dict)
async def submit_expense(
    spbu_id: int, expense_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
):
    try:
        expense = await expense_service.submit_expense(db, spbu_id, expense_id, current_user.id)
    except Exception as e:
        raise _err(e)
    return {"data": expense.model_dump(), "message": "Expense berhasil di-submit"}


@router.post("/{expense_id}/recall", response_model=dict)
async def recall_expense(
    spbu_id: int, expense_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
):
    try:
        expense = await expense_service.recall_expense(db, spbu_id, expense_id, current_user.id)
    except Exception as e:
        raise _err(e)
    return {"data": expense.model_dump(), "message": "Expense berhasil di-recall"}


@router.post("/{expense_id}/review", response_model=dict)
async def review_expense(
    spbu_id: int, expense_id: int, body: ReviewRequest,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
):
    try:
        expense = await expense_service.review_expense(db, spbu_id, expense_id, current_user.id, body.action, body.catatan)
    except Exception as e:
        raise _err(e)
    label = "diapprove" if body.action == "approve" else "ditolak"
    return {"data": expense.model_dump(), "message": f"Expense berhasil {label}"}


@router.post("/{expense_id}/unlock", response_model=dict)
async def unlock_expense(
    spbu_id: int, expense_id: int, body: UnlockRequest,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
):
    try:
        expense = await expense_service.unlock_expense(db, spbu_id, expense_id, current_user.id, body.alasan)
    except Exception as e:
        raise _err(e)
    return {"data": expense.model_dump(), "message": "Expense berhasil di-unlock"}
```

- [ ] **Step 6: Verify backend import**

```bash
cd /Users/dhardhirdhor/Sites/spbu.com/backend
python -c "from app.services import expense_service; print('OK')"
```

- [ ] **Step 7: Commit**

```bash
git add backend/app/schemas/expenses.py backend/app/services/expense_service.py \
        backend/app/repositories/expense_repository.py backend/app/routers/expenses.py
git commit -m "feat(expenses): add submit/recall/review/unlock approval flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 4: Penerimaan Approval — Migration + Backend

**Files:**
- Modify: `backend/app/models/penerimaan.py`
- Create: `backend/alembic/versions/g1j2k3l4m5n6_penerimaan_approval_fields.py`
- Modify: `backend/app/schemas/penerimaan.py`
- Modify: `backend/app/services/penerimaan_service.py`
- Modify: `backend/app/routers/penerimaan.py`

- [ ] **Step 1: Add StatusPenerimaan + audit columns to penerimaan model**

In `backend/app/models/penerimaan.py`, add after `TipeFotoEnum`:

```python
class StatusPenerimaan(str, enum.Enum):
    DRAFT = "draft"
    SUBMITTED = "submitted"
    APPROVED = "approved"
    REJECTED = "rejected"
```

Add to `Penerimaan` class after `created_by_id`:

```python
    status: Mapped[str] = mapped_column(String(20), nullable=False, default=StatusPenerimaan.DRAFT)
    submitted_by_id: Mapped[int | None] = mapped_column(ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True)
    submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    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)
    catatan_review: Mapped[str | None] = mapped_column(Text, nullable=True)
    unlocked_by_id: Mapped[int | None] = mapped_column(ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True)
    unlocked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    unlock_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
```

Add relationships after existing `created_by`:

```python
    submitted_by: Mapped["User | None"] = relationship(foreign_keys=[submitted_by_id])
    reviewed_by: Mapped["User | None"] = relationship(foreign_keys=[reviewed_by_id])
    unlocked_by: Mapped["User | None"] = relationship(foreign_keys=[unlocked_by_id])
```

Need to add `from app.models.user import User` to TYPE_CHECKING block if not already there.

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

Create `backend/alembic/versions/g1j2k3l4m5n6_penerimaan_approval_fields.py`:

```python
"""Add approval fields to penerimaan table.

Revision ID: g1j2k3l4m5n6
Revises: f0i1j2k3l4m5
Create Date: 2026-04-12
"""
from alembic import op
import sqlalchemy as sa

revision = "g1j2k3l4m5n6"
down_revision = "f0i1j2k3l4m5"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.add_column("penerimaan", sa.Column("status", sa.String(20), nullable=False, server_default="draft"))
    op.add_column("penerimaan", sa.Column("submitted_by_id", sa.Integer, sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True))
    op.add_column("penerimaan", sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True))
    op.add_column("penerimaan", sa.Column("reviewed_by_id", sa.Integer, sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True))
    op.add_column("penerimaan", sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
    op.add_column("penerimaan", sa.Column("catatan_review", sa.Text, nullable=True))
    op.add_column("penerimaan", sa.Column("unlocked_by_id", sa.Integer, sa.ForeignKey("master_user.id", ondelete="SET NULL"), nullable=True))
    op.add_column("penerimaan", sa.Column("unlocked_at", sa.DateTime(timezone=True), nullable=True))
    op.add_column("penerimaan", sa.Column("unlock_reason", sa.Text, nullable=True))


def downgrade() -> None:
    for col in ["unlock_reason", "unlocked_at", "unlocked_by_id",
                "catatan_review", "reviewed_at", "reviewed_by_id",
                "submitted_at", "submitted_by_id", "status"]:
        op.drop_column("penerimaan", col)
```

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

```bash
cd /Users/dhardhirdhor/Sites/spbu.com/backend
alembic upgrade head
```

- [ ] **Step 4: Update PenerimaanResponse schema**

In `backend/app/schemas/penerimaan.py`, add to `PenerimaanResponse`:

```python
    status: str = "draft"
    submitted_by_name: Optional[str] = None
    submitted_at: Optional[datetime] = None
    reviewed_by_name: Optional[str] = None
    reviewed_at: Optional[datetime] = None
    catatan_review: Optional[str] = None
    unlocked_by_name: Optional[str] = None
    unlocked_at: Optional[datetime] = None
    unlock_reason: Optional[str] = None
```

Also add `ReviewRequest` and `UnlockRequest` to this schema file (same structure as in expenses schema).

- [ ] **Step 5: Update penerimaan_service.py — _build_response + approval functions**

In `backend/app/services/penerimaan_service.py`, find `_build_response` (or wherever the response is constructed) and add status + audit fields. Then add:

```python
from datetime import datetime, timezone
from app.models.penerimaan import StatusPenerimaan
from sqlalchemy.exc import SQLAlchemyError

async def submit_penerimaan(db, spbu_id: int, penerimaan_id: int, user_id: int):
    from app.repositories import penerimaan_repository
    p = await penerimaan_repository.get_by_id(db, penerimaan_id, spbu_id)
    if p is None:
        raise ValueError("Penerimaan tidak ditemukan")
    if p.status != StatusPenerimaan.DRAFT:
        raise ValueError("Hanya penerimaan DRAFT yang bisa di-submit")
    try:
        p.status = StatusPenerimaan.SUBMITTED
        p.submitted_by_id = user_id
        p.submitted_at = datetime.now(timezone.utc)
        await db.commit()
        await db.refresh(p)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return await get_penerimaan_detail(db, spbu_id, penerimaan_id)


async def review_penerimaan(db, spbu_id: int, penerimaan_id: int, user_id: int, action: str, catatan):
    from app.repositories import penerimaan_repository
    p = await penerimaan_repository.get_by_id(db, penerimaan_id, spbu_id)
    if p is None:
        raise ValueError("Penerimaan tidak ditemukan")
    if p.status != StatusPenerimaan.SUBMITTED:
        raise ValueError("Hanya penerimaan SUBMITTED yang bisa di-review")
    new_status = StatusPenerimaan.APPROVED if action == "approve" else StatusPenerimaan.REJECTED
    try:
        p.status = new_status
        p.reviewed_by_id = user_id
        p.reviewed_at = datetime.now(timezone.utc)
        p.catatan_review = catatan
        await db.commit()
        await db.refresh(p)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return await get_penerimaan_detail(db, spbu_id, penerimaan_id)


async def unlock_penerimaan(db, spbu_id: int, penerimaan_id: int, user_id: int, alasan: str):
    from app.repositories import penerimaan_repository
    p = await penerimaan_repository.get_by_id(db, penerimaan_id, spbu_id)
    if p is None:
        raise ValueError("Penerimaan tidak ditemukan")
    if p.status != StatusPenerimaan.APPROVED:
        raise ValueError("Hanya penerimaan APPROVED yang bisa di-unlock")
    if not alasan:
        raise ValueError("Alasan unlock wajib diisi")
    try:
        p.status = StatusPenerimaan.DRAFT
        p.unlocked_by_id = user_id
        p.unlocked_at = datetime.now(timezone.utc)
        p.unlock_reason = alasan
        await db.commit()
        await db.refresh(p)
    except SQLAlchemyError:
        await db.rollback()
        raise
    return await get_penerimaan_detail(db, spbu_id, penerimaan_id)
```

Also update the delete/foto-upload endpoints in the service to block if status == APPROVED.

- [ ] **Step 6: Add approval endpoints to penerimaan router**

In `backend/app/routers/penerimaan.py`, add:

```python
from app.schemas.penerimaan import ReviewRequest, UnlockRequest

@router.post("/{penerimaan_id}/submit", response_model=dict)
async def submit_penerimaan(spbu_id, penerimaan_id, db=Depends(get_db), current_user=Depends(get_spbu_access)):
    try:
        p = await penerimaan_service.submit_penerimaan(db, spbu_id, penerimaan_id, current_user.id)
    except Exception as e:
        raise _service_error(e)
    return {"data": p.model_dump(), "message": "Penerimaan berhasil di-submit"}

@router.post("/{penerimaan_id}/review", response_model=dict)
async def review_penerimaan(spbu_id, penerimaan_id, body: ReviewRequest, db=Depends(get_db), current_user=Depends(get_spbu_access)):
    try:
        p = await penerimaan_service.review_penerimaan(db, spbu_id, penerimaan_id, current_user.id, body.action, body.catatan)
    except Exception as e:
        raise _service_error(e)
    return {"data": p.model_dump(), "message": "Penerimaan berhasil di-review"}

@router.post("/{penerimaan_id}/unlock", response_model=dict)
async def unlock_penerimaan(spbu_id, penerimaan_id, body: UnlockRequest, db=Depends(get_db), current_user=Depends(get_spbu_access)):
    try:
        p = await penerimaan_service.unlock_penerimaan(db, spbu_id, penerimaan_id, current_user.id, body.alasan)
    except Exception as e:
        raise _service_error(e)
    return {"data": p.model_dump(), "message": "Penerimaan berhasil di-unlock"}
```

Note: The penerimaan router uses `_service_error` (not `_err`). Check existing router for the helper name.

- [ ] **Step 7: Verify backend import**

```bash
python -c "from app.services import penerimaan_service; print('OK')"
```

- [ ] **Step 8: Commit**

```bash
git add backend/app/models/penerimaan.py \
        backend/alembic/versions/g1j2k3l4m5n6_penerimaan_approval_fields.py \
        backend/app/schemas/penerimaan.py \
        backend/app/services/penerimaan_service.py \
        backend/app/routers/penerimaan.py
git commit -m "feat(penerimaan): add status + submit/review/unlock approval flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 5: BAST PDF — Backend

**Files:**
- Modify: `backend/pyproject.toml`
- Create: `backend/app/utils/bast_pdf.py`
- Modify: `backend/app/routers/laporan_shift.py`
- Modify: `backend/app/routers/stock_adjustment.py`
- Modify: `backend/app/routers/expenses.py`
- Modify: `backend/app/routers/penerimaan.py`
- Modify: `backend/app/routers/penyetoran.py`

- [ ] **Step 1: Add reportlab to pyproject.toml**

In `backend/pyproject.toml`, add `"reportlab>=4.0"` to the `dependencies` list.

Install it:

```bash
cd /Users/dhardhirdhor/Sites/spbu.com/backend
pip install reportlab>=4.0
```

- [ ] **Step 2: Create bast_pdf.py**

Create `backend/app/utils/bast_pdf.py`:

```python
"""BAST PDF generator — Berita Acara Serah Terima."""

import io
from datetime import datetime, timezone
from typing import Any

from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib import colors


def _jakarta_now() -> str:
    from zoneinfo import ZoneInfo
    return datetime.now(ZoneInfo("Asia/Jakarta")).strftime("%d %B %Y %H:%M WIB")


def generate_bast(
    modul: str,
    record_id: int,
    spbu_name: str,
    tanggal: str,
    approver_name: str,
    details: list[tuple[str, str]],   # [("Field Label", "Value"), ...]
    items: list[dict] | None = None,   # optional line items
    items_headers: list[str] | None = None,
) -> bytes:
    """
    Generate a BAST PDF and return as bytes.

    Args:
        modul: Module name, e.g. "Penjualan", "Penerimaan"
        record_id: ID of the approved record
        spbu_name: SPBU display name
        tanggal: tanggal record (e.g. "2026-04-12")
        approver_name: name of user who approved
        details: list of (label, value) for the summary section
        items: optional table rows
        items_headers: column headers for items table
    """
    buf = io.BytesIO()
    doc = SimpleDocTemplate(buf, pagesize=A4,
                            leftMargin=2*cm, rightMargin=2*cm,
                            topMargin=2*cm, bottomMargin=2*cm)
    styles = getSampleStyleSheet()
    story = []

    # Title
    title_style = ParagraphStyle("title", parent=styles["Heading1"],
                                  fontSize=14, alignment=1, spaceAfter=4)
    story.append(Paragraph("BERITA ACARA SERAH TERIMA", title_style))
    story.append(Paragraph(f"{modul.upper()} — {spbu_name}", title_style))
    story.append(Spacer(1, 0.4*cm))

    # Metadata
    meta_style = ParagraphStyle("meta", parent=styles["Normal"], fontSize=9, leading=14)
    story.append(Paragraph(f"Tanggal Dokumen: {tanggal}", meta_style))
    story.append(Paragraph(f"Dicetak: {_jakarta_now()}", meta_style))
    story.append(Paragraph(f"Disetujui oleh: {approver_name}", meta_style))
    story.append(Spacer(1, 0.5*cm))

    # Details table
    detail_data = [[Paragraph(f"<b>{k}</b>", meta_style), Paragraph(str(v), meta_style)]
                   for k, v in details]
    detail_table = Table(detail_data, colWidths=[5*cm, 12*cm])
    detail_table.setStyle(TableStyle([
        ("GRID", (0, 0), (-1, -1), 0.3, colors.grey),
        ("BACKGROUND", (0, 0), (0, -1), colors.Color(0.93, 0.93, 0.93)),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 4),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
    ]))
    story.append(detail_table)

    # Optional items table
    if items and items_headers:
        story.append(Spacer(1, 0.5*cm))
        story.append(Paragraph("<b>Detail</b>", meta_style))
        story.append(Spacer(1, 0.2*cm))
        table_data = [items_headers] + [
            [str(row.get(h, "")) for h in items_headers] for row in items
        ]
        col_w = 17 * cm / len(items_headers)
        items_table = Table(table_data, colWidths=[col_w] * len(items_headers))
        items_table.setStyle(TableStyle([
            ("GRID", (0, 0), (-1, -1), 0.3, colors.grey),
            ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.7)),
            ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
            ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
            ("FONTSIZE", (0, 0), (-1, -1), 8),
            ("TOPPADDING", (0, 0), (-1, -1), 3),
            ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
        ]))
        story.append(items_table)

    # Signature block
    story.append(Spacer(1, 1.5*cm))
    sig_data = [
        ["Diserahkan oleh", "", "Diterima / Disetujui oleh"],
        [Paragraph("<br/><br/><br/>( _________________ )", meta_style), "",
         Paragraph(f"<br/><br/><br/>( {approver_name} )", meta_style)],
    ]
    sig_table = Table(sig_data, colWidths=[7*cm, 3*cm, 7*cm])
    sig_table.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")]))
    story.append(sig_table)

    doc.build(story)
    return buf.getvalue()
```

- [ ] **Step 3: Add BAST PDF endpoint to laporan_shift router**

In `backend/app/routers/laporan_shift.py`, add:

```python
from fastapi.responses import Response
from app.utils.bast_pdf import generate_bast

@router.get("/{laporan_id}/bast-pdf")
async def download_bast_laporan(
    spbu_id: int, laporan_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
):
    from app.services import operational_service
    laporan = await operational_service.get_laporan_detail(db, spbu_id, laporan_id)
    if laporan.status not in ("approved", "locked"):
        raise HTTPException(400, detail="BAST hanya tersedia untuk laporan yang sudah Approved")
    spbu_result = await db.execute(
        select(Spbu).where(Spbu.id == spbu_id)
    )
    spbu = spbu_result.scalar_one_or_none()
    spbu_name = spbu.name if spbu else f"SPBU {spbu_id}"
    details = [
        ("Shift", laporan.shift_nama),
        ("Tanggal", str(laporan.tanggal)),
        ("Total Volume", f"{laporan.total_volume} L"),
        ("Total Nilai", f"Rp {laporan.total_nilai:,.0f}"),
        ("Disetujui oleh", laporan.reviewed_by_name or "-"),
        ("Disetujui pada", str(laporan.reviewed_at or "-")),
    ]
    pdf_bytes = generate_bast(
        modul="Penjualan",
        record_id=laporan_id,
        spbu_name=spbu_name,
        tanggal=str(laporan.tanggal),
        approver_name=laporan.reviewed_by_name or "-",
        details=details,
    )
    filename = f"BAST-Penjualan-{spbu_id}-{laporan.tanggal}-shift{laporan.shift_id}.pdf"
    return Response(
        content=pdf_bytes,
        media_type="application/pdf",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )
```

Note: Add `from app.models.spbu import Spbu` and `from sqlalchemy import select` if not already imported in that router.

- [ ] **Step 4: Add BAST endpoint to expenses, penerimaan, penyetoran routers**

Same pattern for each. For expenses:

```python
@router.get("/{expense_id}/bast-pdf")
async def download_bast_expense(
    spbu_id: int, expense_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_spbu_access),
):
    from app.services import expense_service
    expense = await expense_service.get_expense(db, spbu_id, expense_id)
    if expense.status != "approved":
        raise HTTPException(400, detail="BAST hanya tersedia untuk expense yang sudah Approved")
    from app.models.spbu import Spbu
    from sqlalchemy import select as sa_select
    spbu = (await db.execute(sa_select(Spbu).where(Spbu.id == spbu_id))).scalar_one_or_none()
    spbu_name = spbu.name if spbu else f"SPBU {spbu_id}"
    details = [
        ("Tanggal", str(expense.tanggal)),
        ("Kategori", expense.kategori_nama),
        ("Keterangan", expense.keterangan or "-"),
        ("Jumlah", f"Rp {expense.jumlah:,.0f}"),
        ("Disetujui oleh", expense.reviewed_by_name or "-"),
    ]
    pdf_bytes = generate_bast(
        modul="Expenses",
        record_id=expense_id,
        spbu_name=spbu_name,
        tanggal=str(expense.tanggal),
        approver_name=expense.reviewed_by_name or "-",
        details=details,
    )
    filename = f"BAST-Expense-{spbu_id}-{expense.tanggal}-{expense_id}.pdf"
    return Response(content=pdf_bytes, media_type="application/pdf",
                    headers={"Content-Disposition": f'attachment; filename="{filename}"'})
```

For penerimaan, similar pattern using `penerimaan_service.get_penerimaan_detail`. For penyetoran batch: endpoint at `/batches/{batch_id}/bast-pdf`.

Also add BAST for stock_adjustment in `backend/app/routers/stock_adjustment.py` (same pattern as laporan_shift, using `stock_service.get_adjustment_detail`).

- [ ] **Step 5: Verify no import errors**

```bash
python -c "from app.utils.bast_pdf import generate_bast; print('OK')"
python -c "from app.routers import laporan_shift, expenses, penerimaan, penyetoran; print('OK')"
```

- [ ] **Step 6: Commit**

```bash
git add backend/pyproject.toml backend/app/utils/bast_pdf.py \
        backend/app/routers/laporan_shift.py backend/app/routers/stock_adjustment.py \
        backend/app/routers/expenses.py backend/app/routers/penerimaan.py \
        backend/app/routers/penyetoran.py
git commit -m "feat(bast-pdf): add BAST PDF download endpoint to all modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 6: Frontend — Update Types + API + Hooks

**Files:**
- Modify: `frontend/src/types/index.ts`
- Modify: `frontend/src/lib/api/penyetoran.ts`
- Modify: `frontend/src/lib/api/expenses.ts`
- Modify: `frontend/src/lib/api/penerimaan.ts`
- Modify: `frontend/src/lib/hooks/usePenyetoran.ts`
- Modify: `frontend/src/lib/hooks/useExpenses.ts`

- [ ] **Step 1: Update types/index.ts — Penyetoran types**

Replace the `StatusPenyetoran`, `Penyetoran`, and `DailySummary` types:

```typescript
// ── Penyetoran ────────────────────────────────────────────────────────────────

export type StatusPenyetoran = 'draft' | 'submitted' | 'approved'
export type StatusPenyetoranBatch = 'draft' | 'submitted' | 'approved'

export interface Penyetoran {
  id: number
  spbu_id: number
  laporan_shift_id: number
  tanggal: string
  shift_nama: string | null
  jumlah_kas: string
  jumlah_non_kas: string
  total_penjualan: string
  catatan: string | null
  bukti_url: string | null
  status: StatusPenyetoran
  batch_id: number | null
  created_by_name: string | null
  created_at: string
  updated_at: string
}

export interface PenyetoranBatch {
  id: number
  spbu_id: number
  tanggal_from: string
  tanggal_to: string
  total_amount: string
  catatan: string | null
  status: StatusPenyetoranBatch
  submitted_by_name: string | null
  submitted_at: string | null
  reviewed_by_name: string | null
  reviewed_at: string | null
  catatan_review: string | null
  items: Penyetoran[]
  created_at: string
  updated_at: string
}
```

Keep `ShiftSummary`, `ShiftProductSummary`, `ProductTotal`, `DailySummary` as-is (they still work).

- [ ] **Step 2: Add approval fields to Expense + Penerimaan types**

In `types/index.ts`, find the `Expense` interface and add:

```typescript
  status: 'draft' | 'submitted' | 'approved' | 'rejected'
  submitted_by_name: string | null
  submitted_at: string | null
  reviewed_by_name: string | null
  reviewed_at: string | null
  catatan_review: string | null
  recalled_by_name: string | null
  recalled_at: string | null
  unlocked_by_name: string | null
  unlocked_at: string | null
  unlock_reason: string | null
```

In the `Penerimaan` interface, add:

```typescript
  status: 'draft' | 'submitted' | 'approved' | 'rejected'
  submitted_by_name: string | null
  submitted_at: string | null
  reviewed_by_name: string | null
  reviewed_at: string | null
  catatan_review: string | null
  unlocked_by_name: string | null
  unlocked_at: string | null
  unlock_reason: string | null
```

- [ ] **Step 3: Rewrite penyetoran API**

Replace entire `frontend/src/lib/api/penyetoran.ts`:

```typescript
import apiClient from './client'
import type { ApiResponse, Penyetoran, PenyetoranBatch, DailySummary } from '@/types'

export const penyetoranApi = {
  list: (spbuId: number, params?: { tanggal_from?: string; tanggal_to?: string; skip?: number; limit?: number }) =>
    apiClient.get<ApiResponse<Penyetoran[]>>(`/spbus/${spbuId}/penyetoran`, { params }),

  get: (spbuId: number, id: number) =>
    apiClient.get<ApiResponse<Penyetoran>>(`/spbus/${spbuId}/penyetoran/${id}`),

  getDailySummary: (spbuId: number, tanggal: string) =>
    apiClient.get<ApiResponse<DailySummary>>(`/spbus/${spbuId}/penyetoran/summary/${tanggal}`),

  update: (spbuId: number, id: number, data: { catatan?: string }) =>
    apiClient.patch<ApiResponse<Penyetoran>>(`/spbus/${spbuId}/penyetoran/${id}`, data),

  uploadBukti: (spbuId: number, id: number, file: File) => {
    const form = new FormData()
    form.append('file', file)
    return apiClient.post<ApiResponse<Penyetoran>>(
      `/spbus/${spbuId}/penyetoran/${id}/bukti`, form,
      { headers: { 'Content-Type': 'multipart/form-data' } },
    )
  },

  // Batch operations
  listBatches: (spbuId: number, params?: { skip?: number; limit?: number }) =>
    apiClient.get<ApiResponse<PenyetoranBatch[]>>(`/spbus/${spbuId}/penyetoran/batches`, { params }),

  getBatch: (spbuId: number, batchId: number) =>
    apiClient.get<ApiResponse<PenyetoranBatch>>(`/spbus/${spbuId}/penyetoran/batches/${batchId}`),

  createBatch: (spbuId: number, data: { penyetoran_ids: number[]; tanggal_from: string; tanggal_to: string; catatan?: string }) =>
    apiClient.post<ApiResponse<PenyetoranBatch>>(`/spbus/${spbuId}/penyetoran/batches`, data),

  submitBatch: (spbuId: number, batchId: number) =>
    apiClient.post<ApiResponse<PenyetoranBatch>>(`/spbus/${spbuId}/penyetoran/batches/${batchId}/submit`),

  reviewBatch: (spbuId: number, batchId: number, data: { action: 'approve'; catatan?: string }) =>
    apiClient.post<ApiResponse<PenyetoranBatch>>(`/spbus/${spbuId}/penyetoran/batches/${batchId}/review`, data),

  downloadBastBatch: (spbuId: number, batchId: number) =>
    apiClient.get(`/spbus/${spbuId}/penyetoran/batches/${batchId}/bast-pdf`, { responseType: 'blob' }),
}
```

- [ ] **Step 4: Update expenses API**

Add to `frontend/src/lib/api/expenses.ts`:

```typescript
  submit: (spbuId: number, id: number) =>
    apiClient.post<ApiResponse<Expense>>(`/spbus/${spbuId}/expenses/${id}/submit`),

  recall: (spbuId: number, id: number) =>
    apiClient.post<ApiResponse<Expense>>(`/spbus/${spbuId}/expenses/${id}/recall`),

  review: (spbuId: number, id: number, data: { action: 'approve' | 'reject'; catatan?: string }) =>
    apiClient.post<ApiResponse<Expense>>(`/spbus/${spbuId}/expenses/${id}/review`, data),

  unlock: (spbuId: number, id: number, data: { alasan: string }) =>
    apiClient.post<ApiResponse<Expense>>(`/spbus/${spbuId}/expenses/${id}/unlock`, data),

  downloadBast: (spbuId: number, id: number) =>
    apiClient.get(`/spbus/${spbuId}/expenses/${id}/bast-pdf`, { responseType: 'blob' }),
```

- [ ] **Step 5: Update penerimaan API**

Add to `frontend/src/lib/api/penerimaan.ts`:

```typescript
  submit: (spbuId: number, id: number) =>
    apiClient.post(`/spbus/${spbuId}/penerimaan/${id}/submit`),

  review: (spbuId: number, id: number, data: { action: 'approve' | 'reject'; catatan?: string }) =>
    apiClient.post(`/spbus/${spbuId}/penerimaan/${id}/review`, data),

  unlock: (spbuId: number, id: number, data: { alasan: string }) =>
    apiClient.post(`/spbus/${spbuId}/penerimaan/${id}/unlock`, data),

  downloadBast: (spbuId: number, id: number) =>
    apiClient.get(`/spbus/${spbuId}/penerimaan/${id}/bast-pdf`, { responseType: 'blob' }),
```

- [ ] **Step 6: Update usePenyetoran.ts hooks**

Rewrite `frontend/src/lib/hooks/usePenyetoran.ts` to use new API:

```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { penyetoranApi } from '@/lib/api/penyetoran'

export function usePenyetoranList(spbuId: number, params?: { tanggal_from?: string; tanggal_to?: string }) {
  return useQuery({
    queryKey: ['penyetoran', spbuId, params],
    queryFn: () => penyetoranApi.list(spbuId, params).then(r => r.data),
    enabled: !!spbuId,
  })
}

export function usePenyetoranBatchList(spbuId: number) {
  return useQuery({
    queryKey: ['penyetoran-batches', spbuId],
    queryFn: () => penyetoranApi.listBatches(spbuId).then(r => r.data),
    enabled: !!spbuId,
  })
}

export function useCreateBatch(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (data: { penyetoran_ids: number[]; tanggal_from: string; tanggal_to: string; catatan?: string }) =>
      penyetoranApi.createBatch(spbuId, data).then(r => r.data.data),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['penyetoran', spbuId] })
      qc.invalidateQueries({ queryKey: ['penyetoran-batches', spbuId] })
    },
  })
}

export function useSubmitBatch(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (batchId: number) => penyetoranApi.submitBatch(spbuId, batchId).then(r => r.data.data),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['penyetoran-batches', spbuId] }),
  })
}

export function useReviewBatch(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: ({ batchId, action, catatan }: { batchId: number; action: 'approve'; catatan?: string }) =>
      penyetoranApi.reviewBatch(spbuId, batchId, { action, catatan }).then(r => r.data.data),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['penyetoran-batches', spbuId] })
      qc.invalidateQueries({ queryKey: ['penyetoran', spbuId] })
    },
  })
}

export function useDailySummary(spbuId: number, tanggal: string) {
  return useQuery({
    queryKey: ['penyetoran-summary', spbuId, tanggal],
    queryFn: () => penyetoranApi.getDailySummary(spbuId, tanggal).then(r => r.data.data),
    enabled: !!spbuId && !!tanggal,
  })
}
```

- [ ] **Step 7: Add approval hooks to useExpenses.ts**

Add to `frontend/src/lib/hooks/useExpenses.ts`:

```typescript
export function useSubmitExpense(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (id: number) => expensesApi.submit(spbuId, id).then(r => r.data.data),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses', spbuId] }),
  })
}

export function useRecallExpense(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (id: number) => expensesApi.recall(spbuId, id).then(r => r.data.data),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses', spbuId] }),
  })
}

export function useReviewExpense(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: ({ id, action, catatan }: { id: number; action: 'approve' | 'reject'; catatan?: string }) =>
      expensesApi.review(spbuId, id, { action, catatan }).then(r => r.data.data),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses', spbuId] }),
  })
}

export function useUnlockExpense(spbuId: number) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: ({ id, alasan }: { id: number; alasan: string }) =>
      expensesApi.unlock(spbuId, id, { alasan }).then(r => r.data.data),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses', spbuId] }),
  })
}
```

- [ ] **Step 8: Commit**

```bash
git add frontend/src/types/index.ts \
        frontend/src/lib/api/penyetoran.ts \
        frontend/src/lib/api/expenses.ts \
        frontend/src/lib/api/penerimaan.ts \
        frontend/src/lib/hooks/usePenyetoran.ts \
        frontend/src/lib/hooks/useExpenses.ts
git commit -m "feat(frontend): update types + API + hooks for approval flows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 7: Frontend — Penyetoran Redesign

**File:** `frontend/src/app/(dashboard)/penyetoran/penyetoran-client.tsx`

The current UI was designed for the old (daily, manual-create) model. Replace with per-shift list + batch submission.

- [ ] **Step 1: Rewrite penyetoran-client.tsx**

Replace the entire file with the new design. Key structure:

```tsx
'use client'

import { PageGuard } from '@/components/ui/page-guard'
import { usePermission } from '@/lib/hooks/usePermission'
import { useState } from 'react'
import { useSpbuStore } from '@/stores/spbu-store'
import {
  usePenyetoranList, usePenyetoranBatchList,
  useCreateBatch, useSubmitBatch, useReviewBatch,
} from '@/lib/hooks/usePenyetoran'
import { penyetoranApi } from '@/lib/api/penyetoran'
import { formatDate, formatRupiah } from '@/lib/utils/format'
import type { Penyetoran, PenyetoranBatch } from '@/types'

// ── Status Badge ──────────────────────────────────────────────────────────────

function StatusBadge({ status }: { status: string }) {
  const map: Record<string, { label: string; cls: string }> = {
    draft:     { label: 'Draft',     cls: 'bg-secondary-lt text-secondary' },
    submitted: { label: 'Submitted', cls: 'bg-yellow-lt text-yellow' },
    approved:  { label: 'Approved',  cls: 'bg-success-lt text-success' },
  }
  const { label, cls } = map[status] ?? { label: status, cls: 'bg-secondary-lt' }
  return <span className={`badge ${cls}`}>{label}</span>
}

// ── Penyetoran Table (with checkbox selection) ────────────────────────────────

function PenyetoranTable({
  items,
  selected,
  onToggle,
  canSelect,
}: {
  items: Penyetoran[]
  selected: Set<number>
  onToggle: (id: number) => void
  canSelect: boolean
}) {
  if (items.length === 0)
    return <div className="text-muted p-3">Tidak ada data penyetoran.</div>

  return (
    <div className="table-responsive">
      <table className="table table-vcenter table-hover">
        <thead>
          <tr>
            {canSelect && <th style={{ width: 36 }} />}
            <th>Tanggal</th>
            <th>Shift</th>
            <th className="text-end">Kas</th>
            <th className="text-end">Non-Kas</th>
            <th className="text-end">Total Penjualan</th>
            <th>Status</th>
          </tr>
        </thead>
        <tbody>
          {items.map((p) => (
            <tr key={p.id}>
              {canSelect && (
                <td>
                  {p.status === 'draft' && (
                    <input
                      type="checkbox"
                      className="form-check-input"
                      checked={selected.has(p.id)}
                      onChange={() => onToggle(p.id)}
                    />
                  )}
                </td>
              )}
              <td>{formatDate(p.tanggal)}</td>
              <td>{p.shift_nama ?? '-'}</td>
              <td className="text-end">{formatRupiah(p.jumlah_kas)}</td>
              <td className="text-end">{formatRupiah(p.jumlah_non_kas)}</td>
              <td className="text-end">{formatRupiah(p.total_penjualan)}</td>
              <td><StatusBadge status={p.status} /></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

// ── Batch List ────────────────────────────────────────────────────────────────

function BatchTable({
  batches,
  canApprove,
  onApprove,
}: {
  batches: PenyetoranBatch[]
  canApprove: boolean
  onApprove: (batchId: number) => void
}) {
  if (batches.length === 0)
    return <div className="text-muted p-3">Belum ada batch.</div>

  return (
    <div className="table-responsive">
      <table className="table table-vcenter">
        <thead>
          <tr>
            <th>Periode</th>
            <th className="text-end">Total</th>
            <th>Status</th>
            <th>Submitted by</th>
            <th>Reviewed by</th>
            <th />
          </tr>
        </thead>
        <tbody>
          {batches.map((b) => (
            <tr key={b.id}>
              <td>{formatDate(b.tanggal_from)} – {formatDate(b.tanggal_to)}</td>
              <td className="text-end">{formatRupiah(b.total_amount)}</td>
              <td><StatusBadge status={b.status} /></td>
              <td>{b.submitted_by_name ?? '-'}</td>
              <td>{b.reviewed_by_name ?? '-'}</td>
              <td>
                {canApprove && b.status === 'submitted' && (
                  <button
                    className="btn btn-sm btn-success"
                    onClick={() => onApprove(b.id)}
                  >
                    Approve
                  </button>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

// ── Main Page ─────────────────────────────────────────────────────────────────

export default function PenyetoranClient() {
  const spbuId = useSpbuStore((s) => s.selectedSpbuId)
  const canApprove = usePermission('penyetoran', 'approve')
  const canCreate = usePermission('penyetoran', 'create')

  const [activeTab, setActiveTab] = useState<'list' | 'batches'>('list')
  const [dateFrom, setDateFrom] = useState('')
  const [dateTo, setDateTo] = useState('')
  const [selected, setSelected] = useState<Set<number>>(new Set())
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState('')

  const listQ = usePenyetoranList(spbuId!, {
    tanggal_from: dateFrom || undefined,
    tanggal_to: dateTo || undefined,
  })
  const batchQ = usePenyetoranBatchList(spbuId!)
  const createBatchMut = useCreateBatch(spbuId!)
  const reviewBatchMut = useReviewBatch(spbuId!)

  const items: Penyetoran[] = (listQ.data as any)?.data ?? []
  const batches: PenyetoranBatch[] = (batchQ.data as any)?.data ?? []

  function toggleSelect(id: number) {
    setSelected((prev) => {
      const next = new Set(prev)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  }

  async function handleCreateBatch() {
    if (selected.size === 0) { setError('Pilih minimal 1 penyetoran'); return }
    if (!dateFrom || !dateTo) { setError('Pilih rentang tanggal batch'); return }
    setError('')
    setSubmitting(true)
    try {
      await createBatchMut.mutateAsync({
        penyetoran_ids: Array.from(selected),
        tanggal_from: dateFrom,
        tanggal_to: dateTo,
      })
      setSelected(new Set())
      setActiveTab('batches')
    } catch (e: any) {
      setError(e?.response?.data?.detail ?? 'Gagal membuat batch')
    } finally {
      setSubmitting(false)
    }
  }

  async function handleApproveBatch(batchId: number) {
    try {
      await reviewBatchMut.mutateAsync({ batchId, action: 'approve' })
    } catch (e: any) {
      setError(e?.response?.data?.detail ?? 'Gagal approve batch')
    }
  }

  if (!spbuId) return null

  return (
    <PageGuard modul="penyetoran" aksi="view">
      <div className="container-xl">
        <div className="page-header mb-3">
          <div className="row align-items-center">
            <div className="col">
              <h2 className="page-title">Penyetoran</h2>
            </div>
          </div>
        </div>

        {/* Date Filter */}
        <div className="card mb-3">
          <div className="card-body">
            <div className="row g-2 align-items-end">
              <div className="col-auto">
                <label className="form-label">Dari</label>
                <input type="date" className="form-control" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
              </div>
              <div className="col-auto">
                <label className="form-label">Sampai</label>
                <input type="date" className="form-control" value={dateTo} onChange={e => setDateTo(e.target.value)} />
              </div>
              <div className="col-auto">
                <button className="btn" onClick={() => { setDateFrom(''); setDateTo('') }}>Reset</button>
              </div>
            </div>
          </div>
        </div>

        {error && <div className="alert alert-danger">{error}</div>}

        {/* Tabs */}
        <div className="card">
          <div className="card-header">
            <ul className="nav nav-tabs card-header-tabs">
              <li className="nav-item">
                <button className={`nav-link ${activeTab === 'list' ? 'active' : ''}`} onClick={() => setActiveTab('list')}>
                  Per Shift
                </button>
              </li>
              <li className="nav-item">
                <button className={`nav-link ${activeTab === 'batches' ? 'active' : ''}`} onClick={() => setActiveTab('batches')}>
                  Batch
                </button>
              </li>
            </ul>
          </div>
          <div className="card-body p-0">
            {activeTab === 'list' && (
              <>
                {canCreate && selected.size > 0 && (
                  <div className="px-3 pt-3">
                    <button
                      className="btn btn-primary"
                      disabled={submitting}
                      onClick={handleCreateBatch}
                    >
                      {submitting ? 'Memproses...' : `Kirim ke Manager (${selected.size} shift)`}
                    </button>
                    <span className="text-muted ms-2" style={{ fontSize: '0.85rem' }}>
                      Pastikan rentang tanggal di atas sudah diisi
                    </span>
                  </div>
                )}
                {listQ.isLoading ? (
                  <div className="p-3"><div className="spinner-border spinner-border-sm" /></div>
                ) : (
                  <PenyetoranTable
                    items={items}
                    selected={selected}
                    onToggle={toggleSelect}
                    canSelect={canCreate}
                  />
                )}
              </>
            )}
            {activeTab === 'batches' && (
              batchQ.isLoading ? (
                <div className="p-3"><div className="spinner-border spinner-border-sm" /></div>
              ) : (
                <BatchTable
                  batches={batches}
                  canApprove={canApprove}
                  onApprove={handleApproveBatch}
                />
              )
            )}
          </div>
        </div>
      </div>
    </PageGuard>
  )
}
```

- [ ] **Step 2: Verify TypeScript compiles**

```bash
cd /Users/dhardhirdhor/Sites/spbu.com/frontend
npx tsc --noEmit 2>&1 | grep penyetoran
```

Expected: no errors for penyetoran files.

- [ ] **Step 3: Commit**

```bash
git add frontend/src/app/(dashboard)/penyetoran/penyetoran-client.tsx
git commit -m "feat(penyetoran): redesign frontend for per-shift + batch model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 8: Frontend — Expenses + Penerimaan Approval UI

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

The existing expenses-client.tsx has an `ExpenseForm` and a list view. The changes needed are:
1. Add `StatusBadge` component
2. Add Submit / Recall / Approve / Reject / Unlock action buttons (conditional on permission + status)
3. Add `AuditTimeline` to the detail view
4. Block Edit/Delete when status != 'draft'

- [ ] **Step 1: Add approval UI to expenses-client.tsx**

Add `StatusBadge` at top of the file:

```tsx
function StatusBadge({ status }: { status: string }) {
  const map: Record<string, { label: string; cls: string }> = {
    draft:     { label: 'Draft',     cls: 'bg-secondary-lt text-secondary' },
    submitted: { label: 'Submitted', cls: 'bg-yellow-lt text-yellow' },
    approved:  { label: 'Approved',  cls: 'bg-success-lt text-success' },
    rejected:  { label: 'Rejected',  cls: 'bg-danger-lt text-danger' },
  }
  const { label, cls } = map[status] ?? { label: status, cls: 'bg-secondary-lt' }
  return <span className={`badge ${cls}`}>{label}</span>
}
```

Add imports for new hooks:

```tsx
import {
  useExpenseKategori, useExpenseList, useCreateExpense, useUpdateExpense,
  useDeleteExpense, useUploadBukti,
  useSubmitExpense, useRecallExpense, useReviewExpense, useUnlockExpense,
} from '@/lib/hooks/useExpenses'
import { expensesApi } from '@/lib/api/expenses'
```

In the expense list table, add a `Status` column and action buttons per row. For each expense row:
- Show `<StatusBadge status={expense.status} />`
- Show Edit/Delete only if `expense.status === 'draft'`
- Show Submit button if `expense.status === 'draft'` and user has `expenses:create`
- Show Approve/Reject buttons if `expense.status === 'submitted'` and user has `expenses:approve`
- Show Unlock button if `expense.status === 'approved'` and user has `expenses:approve`
- Show BAST PDF download if `expense.status === 'approved'`

Add an `UnlockModal` component:

```tsx
function UnlockModal({
  onConfirm,
  onClose,
  isPending,
}: {
  onConfirm: (alasan: string) => void
  onClose: () => void
  isPending: boolean
}) {
  const [alasan, setAlasan] = useState('')
  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">Unlock Expense</h5>
            <button className="btn-close" onClick={onClose} />
          </div>
          <div className="modal-body">
            <label className="form-label">Alasan unlock <span className="text-danger">*</span></label>
            <textarea
              className="form-control"
              rows={3}
              value={alasan}
              onChange={e => setAlasan(e.target.value)}
              placeholder="Wajib diisi..."
            />
          </div>
          <div className="modal-footer">
            <button className="btn" onClick={onClose}>Batal</button>
            <button
              className="btn btn-warning"
              disabled={!alasan.trim() || isPending}
              onClick={() => onConfirm(alasan)}
            >
              Unlock
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}
```

In the main component, add state and handlers:

```tsx
const canApprove = usePermission('expenses', 'approve')
const submitMut = useSubmitExpense(spbuId!)
const recallMut = useRecallExpense(spbuId!)
const reviewMut = useReviewExpense(spbuId!)
const unlockMut = useUnlockExpense(spbuId!)
const [unlockTarget, setUnlockTarget] = useState<number | null>(null)

function downloadBast(id: number) {
  expensesApi.downloadBast(spbuId!, id).then(r => {
    const url = URL.createObjectURL(new Blob([r.data], { type: 'application/pdf' }))
    const a = document.createElement('a')
    a.href = url; a.download = `BAST-Expense-${id}.pdf`; a.click()
    URL.revokeObjectURL(url)
  })
}
```

- [ ] **Step 2: Add approval UI to penerimaan-client.tsx**

Same pattern as expenses but simpler (no recall — penerimaan does not have `recalled_by` in the model, so no recall step). Add:
- Submit / Approve / Reject / Unlock action buttons
- StatusBadge
- BAST download
- Block add-foto when status != 'draft'

Find where photos upload button is rendered and wrap with:
```tsx
{penerimaan.status === 'draft' && (
  <button ...>Upload Foto</button>
)}
```

- [ ] **Step 3: TypeScript check**

```bash
cd /Users/dhardhirdhor/Sites/spbu.com/frontend
npx tsc --noEmit 2>&1 | grep -E "expenses|penerimaan" | head -20
```

Expected: no type errors.

- [ ] **Step 4: Commit**

```bash
git add frontend/src/app/(dashboard)/expenses/expenses-client.tsx \
        frontend/src/app/(dashboard)/penerimaan/penerimaan-client.tsx
git commit -m "feat(frontend): add approval flow UI to expenses + penerimaan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 9: Frontend — BAST PDF Buttons on Penjualan + Stock

These modules already have approval flow (laporan_shift). Just add a download button.

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

- [ ] **Step 1: Add BAST download to penjualan**

In `frontend/src/lib/api/penjualan.ts`, add:

```typescript
  downloadBast: (spbuId: number, id: number) =>
    apiClient.get(`/spbus/${spbuId}/laporan-shift/${id}/bast-pdf`, { responseType: 'blob' }),
```

In `penjualan-client.tsx`, find the detail modal or row actions for approved laporan. Add download button:

```tsx
{laporan.status === 'approved' && (
  <button
    className="btn btn-sm btn-outline-secondary"
    onClick={() => {
      penjualanApi.downloadBast(spbuId!, laporan.id).then(r => {
        const url = URL.createObjectURL(new Blob([r.data], { type: 'application/pdf' }))
        const a = document.createElement('a')
        a.href = url; a.download = `BAST-Penjualan-${laporan.id}.pdf`; a.click()
        URL.revokeObjectURL(url)
      })
    }}
  >
    BAST PDF
  </button>
)}
```

- [ ] **Step 2: Same for stock-client.tsx**

In `frontend/src/lib/api/stock.ts`, add:

```typescript
  downloadBast: (spbuId: number, id: number) =>
    apiClient.get(`/spbus/${spbuId}/stock-adjustment/${id}/bast-pdf`, { responseType: 'blob' }),
```

In `stock-client.tsx`, add same button pattern for approved stock adjustments.

- [ ] **Step 3: Commit**

```bash
git add frontend/src/app/(dashboard)/penjualan/penjualan-client.tsx \
        frontend/src/app/(dashboard)/stock/stock-client.tsx \
        frontend/src/lib/api/penjualan.ts frontend/src/lib/api/stock.ts
git commit -m "feat(frontend): add BAST PDF download to penjualan + stock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Self-Review

**Spec coverage check:**
- ✅ Backend 1: Expenses approval flow — Tasks 2 + 3
- ✅ Backend 2: Approved=locked enforcement — `update_expense` + `delete_expense` block in Task 3; penerimaan foto block in Task 4
- ✅ Backend 3: BAST PDF endpoints — Task 5
- ✅ Frontend 1: Penyetoran redesign — Task 7
- ✅ Frontend 2: Expenses approval UI — Task 8
- ✅ Frontend 3: Penerimaan approval UI — Task 8
- ✅ Frontend 4: BAST PDF download buttons — Tasks 8 + 9

**Known gaps / watchouts:**
1. `penyetoran_service.py` still imports `ConfirmRequest` from old schema — remove this import when rewriting
2. The `penerimaan_repository.py` needs to add selectinload for `submitted_by`, `reviewed_by`, `unlocked_by` — check the file and update like we did for expense_repository
3. `stock_adjustment` router needs the same `_build_response`-style update for the `reviewed_by` field display in BAST — check if the existing `get_adjustment_detail` already loads it
4. When creating a batch via `create_batch`, the status is set to DRAFT with `submitted_by_id` already set — this seems inconsistent. The batch is created as DRAFT, then separately submitted. Remove `submitted_by_id` from the create step; set it only in `submit_batch`.
5. The `Penyetoran` model `laporan_shift` relationship needs to eagerly load `shift` — the `_eager_opts` in the new repository adds `selectinload(Penyetoran.laporan_shift)` but not its nested `shift`. Fix: `selectinload(Penyetoran.laporan_shift).selectinload(LaporanShift.shift)`.
