     1→'use client'
     2→
     3→import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
     4→import { toast } from 'sonner'
     5→import {
     6→  BookOpen,
     7→  Download,
     8→  Plus,
     9→  Search,
    10→  RotateCcw,
    11→  MoreHorizontal,
    12→  Eye,
    13→  Pencil,
    14→  Trash2,
    15→  ArrowUpDown,
    16→  ArrowUp,
    17→  ArrowDown,
    18→  Paperclip,
    19→  ChevronLeft,
    20→  ChevronRight,
    21→  FileText,
    22→} from 'lucide-react'
    23→
    24→import { PageHeader } from '../page-header'
    25→import { useNav } from '@/lib/nav-store'
    26→import { formatCurrency, formatDate } from '@/lib/format'
    27→import { cn } from '@/lib/utils'
    28→
    29→import { Button } from '@/components/ui/button'
    30→import { Input } from '@/components/ui/input'
    31→import { Badge } from '@/components/ui/badge'
    32→import { Skeleton } from '@/components/ui/skeleton'
    33→import {
    34→  Table,
    35→  TableBody,
    36→  TableCell,
    37→  TableHead,
    38→  TableHeader,
    39→  TableRow,
    40→} from '@/components/ui/table'
    41→import {
    42→  Select,
    43→  SelectContent,
    44→  SelectItem,
    45→  SelectTrigger,
    46→  SelectValue,
    47→} from '@/components/ui/select'
    48→import {
    49→  DropdownMenu,
    50→  DropdownMenuContent,
    51→  DropdownMenuItem,
    52→  DropdownMenuSeparator,
    53→  DropdownMenuTrigger,
    54→} from '@/components/ui/dropdown-menu'
    55→import {
    56→  Dialog,
    57→  DialogContent,
    58→  DialogDescription,
    59→  DialogHeader,
    60→  DialogTitle,
    61→} from '@/components/ui/dialog'
    62→import {
    63→  AlertDialog,
    64→  AlertDialogAction,
    65→  AlertDialogCancel,
    66→  AlertDialogContent,
    67→  AlertDialogDescription,
    68→  AlertDialogFooter,
    69→  AlertDialogHeader,
    70→  AlertDialogTitle,
    71→} from '@/components/ui/alert-dialog'
    72→
    73→// ---------- Types ----------
    74→type RelatedPartyLite = { id: string; nama: string; jenis: string }
    75→type AccountLite = {
    76→  id: string
    77→  kodeAkun: string
    78→  namaAkun: string
    79→  jenisAkun: string
    80→  posisi: string
    81→}
    82→
    83→type JournalListItem = {
    84→  id: string
    85→  tanggal: string
    86→  noBukti: string
    87→  noRef: string | null
    88→  pihakTerkaitId: string | null
    89→  pihakTerkait: { id: string; nama: string } | null
    90→  tanggalJatuhTempo: string | null
    91→  klasifikasiFiskal: string
    92→  transactionType: string
    93→  keterangan: string | null
    94→  status: string
    95→  totalDebet: number
    96→  totalKredit: number
    97→  attachmentCount: number
    98→}
    99→
   100→type JournalDetailRow = {
   101→  id: string
   102→  coaId: string
   103→  debet: number
   104→  kredit: number
   105→  keterangan: string | null
   106→  coa: AccountLite | null
   107→}
   108→
   109→type JournalAttachmentRow = {
   110→  id: string
   111→  fileName: string
   112→  fileType: string
   113→  fileSize: number
   114→  fileData: string
   115→  uploadedAt: string
   116→}
   117→
   118→type JournalFull = {
   119→  id: string
   120→  tanggal: string
   121→  noBukti: string
   122→  noRef: string | null
   123→  pihakTerkaitId: string | null
   124→  pihakTerkait: RelatedPartyLite | null
   125→  tanggalJatuhTempo: string | null
   126→  klasifikasiFiskal: string
   127→  transactionType: string
   128→  keterangan: string | null
   129→  status: string
   130→  totalDebet: number
   131→  totalKredit: number
   132→  details: JournalDetailRow[]
   133→  attachments: JournalAttachmentRow[]
   134→}
   135→
   136→type ListResponse = {
   137→  data: JournalListItem[]
   138→  total: number
   139→  page: number
   140→  limit: number
   141→  totalPages: number
   142→  totals: { totalDebet: number; totalKredit: number }
   143→}
   144→
   145→// ---------- Constants ----------
   146→const KLASIFIKASI_OPTIONS = [
   147→  'Komersial',
   148→  'Tidak Termasuk Objek Pajak',
   149→  'PPh Final',
   150→  'Penyesuaian Fiskal Positif',
   151→  'Penyesuaian Fiskal Negatif',
   152→]
   153→
   154→const TYPE_OPTIONS = [
   155→  { value: 'General', label: 'General' },
   156→  { value: 'Receivable', label: 'Receivable' },
   157→  { value: 'Payable', label: 'Payable' },
   158→]
   159→
   160→const SORT_OPTIONS = [
   161→  { value: 'tanggal', label: 'Tanggal' },
   162→  { value: 'noBukti', label: 'No. Bukti' },
   163→  { value: 'totalDebet', label: 'Total Debet' },
   164→  { value: 'totalKredit', label: 'Total Kredit' },
   165→  { value: 'createdAt', label: 'Dibuat' },
   166→]
   167→
   168→const LIMIT_OPTIONS = [
   169→  { value: '10', label: '10' },
   170→  { value: '20', label: '20' },
   171→  { value: '50', label: '50' },
   172→  { value: '100', label: '100' },
   173→  { value: '0', label: 'Semua' },
   174→]
   175→
   176→// ---------- Helpers ----------
   177→function klasifikasiBadgeVariant(k: string) {
   178→  switch (k) {
   179→    case 'PPh Final':
   180→      return 'bg-primary/10 text-primary border-primary/20'
   181→    case 'Penyesuaian Fiskal Positif':
   182→      return 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
   183→    case 'Penyesuaian Fiskal Negatif':
   184→      return 'bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/20'
   185→    case 'Tidak Termasuk Objek Pajak':
   186→      return 'bg-muted text-muted-foreground border-border'
   187→    default:
   188→      return 'bg-secondary text-secondary-foreground border-transparent'
   189→  }
   190→}
   191→
   192→function typeBadgeClass(t: string) {
   193→  switch (t) {
   194→    case 'Receivable':
   195→      return 'bg-primary/10 text-primary border-primary/20'
   196→    case 'Payable':
   197→      return 'bg-secondary text-secondary-foreground border-transparent'
   198→    default:
   199→      return 'bg-muted text-muted-foreground border-border'
   200→  }
   201→}
   202→
   203→function statusBadgeClass(s: string) {
   204→  return s === 'Balanced'
   205→    ? 'bg-primary/10 text-primary border-primary/20'
   206→    : 'bg-destructive/10 text-destructive border-destructive/20'
   207→}
   208→
   209→function formatBytes(bytes: number): string {
   210→  if (!bytes) return '0 B'
   211→  const k = 1024
   212→  const sizes = ['B', 'KB', 'MB', 'GB']
   213→  const i = Math.floor(Math.log(bytes) / Math.log(k))
   214→  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
   215→}
   216→
   217→// ---------- Component ----------
   218→export default function JournalsView() {
   219→  const { setView, params } = useNav()
   220→
   221→  // Filter state
   222→  const [search, setSearch] = useState('')
   223→  const [debouncedSearch, setDebouncedSearch] = useState('')
   224→  const [pihakTerkaitId, setPihakTerkaitId] = useState('all')
   225→  const [klasifikasiFiskal, setKlasifikasiFiskal] = useState('all')
   226→  const [transactionType, setTransactionType] = useState('all')
   227→  const [status, setStatus] = useState('all')
   228→  const [dateFrom, setDateFrom] = useState('')
   229→  const [dateTo, setDateTo] = useState('')
   230→  const [sortField, setSortField] = useState('tanggal')
   231→  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
   232→  const [page, setPage] = useState(1)
   233→  const [limit, setLimit] = useState(10)
   234→
   235→  // Data state
   236→  const [data, setData] = useState<JournalListItem[]>([])
   237→  const [total, setTotal] = useState(0)
   238→  const [totalPages, setTotalPages] = useState(1)
   239→  const [totals, setTotals] = useState({ totalDebet: 0, totalKredit: 0 })
   240→  const [loading, setLoading] = useState(true)
   241→  const [error, setError] = useState<string | null>(null)
   242→
   243→  // Related parties for filter dropdown
   244→  const [relatedParties, setRelatedParties] = useState<RelatedPartyLite[]>([])
   245→
   246→  // Detail dialog
   247→  const [detailOpen, setDetailOpen] = useState(false)
   248→  const [detailLoading, setDetailLoading] = useState(false)
   249→  const [detailData, setDetailData] = useState<JournalFull | null>(null)
   250→
   251→  // Delete dialog
   252→  const [deleteId, setDeleteId] = useState<string | null>(null)
   253→  const [deleting, setDeleting] = useState(false)
   254→
   255→  // Initial load of related parties (once) + handle edit param
   256→  useEffect(() => {
   257→    let cancelled = false
   258→    ;(async () => {
   259→      try {
   260→        const res = await fetch('/api/coa/flat', { cache: 'no-store' })
   261→        if (!res.ok) throw new Error('Gagal memuat data')
   262→        const json = await res.json()
   263→        if (cancelled) return
   264→        setRelatedParties(json.relatedParties || [])
   265→      } catch (e: any) {
   266→        if (!cancelled) toast.error(e?.message ?? 'Gagal memuat pihak terkait')
   267→      }
   268→    })()
   269→
   270→    // If navigated with edit param, jump straight to create view in edit mode
   271→    if (params?.editJournalId) {
   272→      setView('journal-create', { editId: params.editJournalId })
   273→    }
   274→    return () => {
   275→      cancelled = true
   276→    }
   277→  }, [])
   278→
   279→  // Debounce search 400ms
   280→  useEffect(() => {
   281→    const t = setTimeout(() => {
   282→      setDebouncedSearch(search)
   283→      setPage(1)
   284→    }, 400)
   285→    return () => clearTimeout(t)
   286→  }, [search])
   287→
   288→  // Build query string from filters
   289→  const queryString = useMemo(() => {
   290→    const params = new URLSearchParams()
   291→    if (debouncedSearch) params.set('search', debouncedSearch)
   292→    if (pihakTerkaitId && pihakTerkaitId !== 'all')
   293→      params.set('pihakTerkaitId', pihakTerkaitId)
   294→    if (klasifikasiFiskal && klasifikasiFiskal !== 'all')
   295→      params.set('klasifikasiFiskal', klasifikasiFiskal)
   296→    if (transactionType && transactionType !== 'all')
   297→      params.set('transactionType', transactionType)
   298→    if (status && status !== 'all') params.set('status', status)
   299→    if (dateFrom) params.set('dateFrom', dateFrom)
   300→    if (dateTo) params.set('dateTo', dateTo)
   301→    params.set('sort', `${sortField}:${sortDir}`)
   302→    params.set('page', String(page))
   303→    params.set('limit', String(limit))
   304→    return params.toString()
   305→  }, [
   306→    debouncedSearch,
   307→    pihakTerkaitId,
   308→    klasifikasiFiskal,
   309→    transactionType,
   310→    status,
   311→    dateFrom,
   312→    dateTo,
   313→    sortField,
   314→    sortDir,
   315→    page,
   316→    limit,
   317→  ])
   318→
   319→  // Fetch journals whenever filters change
   320→  const fetchJournals = useCallback(async () => {
   321→    setLoading(true)
   322→    setError(null)
   323→    try {
   324→      const res = await fetch(`/api/journals?${queryString}`, { cache: 'no-store' })
   325→      if (!res.ok) throw new Error('Gagal memuat jurnal')
   326→      const json: ListResponse = await res.json()
   327→      setData(json.data)
   328→      setTotal(json.total)
   329→      setTotalPages(json.totalPages)
   330→      setTotals(json.totals)
   331→    } catch (e: any) {
   332→      setError(e?.message ?? 'Gagal memuat jurnal')
   333→      toast.error(e?.message ?? 'Gagal memuat jurnal')
   334→    } finally {
   335→      setLoading(false)
   336→    }
   337→  }, [queryString])
   338→
   339→  useEffect(() => {
   340→    fetchJournals()
   341→  }, [fetchJournals])
   342→
   343→  function handleReset() {
   344→    setSearch('')
   345→    setDebouncedSearch('')
   346→    setPihakTerkaitId('all')
   347→    setKlasifikasiFiskal('all')
   348→    setTransactionType('all')
   349→    setStatus('all')
   350→    setDateFrom('')
   351→    setDateTo('')
   352→    setSortField('tanggal')
   353→    setSortDir('desc')
   354→    setPage(1)
   355→    setLimit(10)
   356→  }
   357→
   358→  function handleSortToggle() {
   359→    setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
   360→  }
   361→
   362→  // ---------- Export CSV ----------
   363→  async function handleExportCSV() {
   364→    try {
   365→      toast.info('Menyiapkan CSV…')
   366→      // Fetch all matching rows
   367→      const exportParams = new URLSearchParams(queryString)
   368→      exportParams.set('limit', '0')
   369→      const res = await fetch(`/api/journals?${exportParams.toString()}`, {
   370→        cache: 'no-store',
   371→      })
   372→      if (!res.ok) throw new Error('Gagal mengekspor data')
   373→      const json: ListResponse = await res.json()
   374→      const rows = json.data
   375→      const header = [
   376→        'Tanggal',
   377→        'No Bukti',
   378→        'Pihak Terkait',
   379→        'No Ref',
   380→        'Klasifikasi Fiskal',
   381→        'Tipe Transaksi',
   382→        'Keterangan',
   383→        'Total Debet',
   384→        'Total Kredit',
   385→        'Status',
   386→      ]
   387→      const escape = (v: string | number | null | undefined) => {
   388→        const s = v === null || v === undefined ? '' : String(v)
   389→        if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`
   390→        return s
   391→      }
   392→      const lines = [header.map(escape).join(',')]
   393→      for (const r of rows) {
   394→        lines.push(
   395→          [
   396→            formatDate(r.tanggal),
   397→            r.noBukti,
   398→            r.pihakTerkait?.nama ?? '',
   399→            r.noRef ?? '',
   400→            r.klasifikasiFiskal,
   401→            r.transactionType,
   402→            r.keterangan ?? '',
   403→            r.totalDebet,
   404→            r.totalKredit,
   405→            r.status,
   406→          ]
   407→            .map(escape)
   408→            .join(',')
   409→        )
   410→      }
   411→      const csv = lines.join('\n')
   412→      const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' })
   413→      const url = URL.createObjectURL(blob)
   414→      const a = document.createElement('a')
   415→      a.href = url
   416→      a.download = `jurnal-transaksi-${new Date().toISOString().slice(0, 10)}.csv`
   417→      document.body.appendChild(a)
   418→      a.click()
   419→      document.body.removeChild(a)
   420→      URL.revokeObjectURL(url)
   421→      toast.success(`Diekspor ${rows.length} baris jurnal`)
   422→    } catch (e: any) {
   423→      toast.error(e?.message ?? 'Gagal mengekspor CSV')
   424→    }
   425→  }
   426→
   427→  // ---------- Detail ----------
   428→  async function openDetail(id: string) {
   429→    setDetailOpen(true)
   430→    setDetailLoading(true)
   431→    setDetailData(null)
   432→    try {
   433→      const res = await fetch(`/api/journals/${id}`, { cache: 'no-store' })
   434→      if (!res.ok) throw new Error('Gagal memuat detail jurnal')
   435→      const json = await res.json()
   436→      setDetailData(json.data as JournalFull)
   437→    } catch (e: any) {
   438→      toast.error(e?.message ?? 'Gagal memuat detail jurnal')
   439→      setDetailOpen(false)
   440→    } finally {
   441→      setDetailLoading(false)
   442→    }
   443→  }
   444→
   445→  // ---------- Delete ----------
   446→  async function handleConfirmDelete() {
   447→    if (!deleteId) return
   448→    setDeleting(true)
   449→    try {
   450→      const res = await fetch(`/api/journals/${deleteId}`, { method: 'DELETE' })
   451→      if (!res.ok) {
   452→        const j = await res.json().catch(() => ({}))
   453→        throw new Error(j.error ?? 'Gagal menghapus jurnal')
   454→      }
   455→      toast.success('Jurnal berhasil dihapus')
   456→      setDeleteId(null)
   457→      // Refresh list (re-fetch by re-triggering effect via state change)
   458→      fetchJournals()
   459→    } catch (e: any) {
   460→      toast.error(e?.message ?? 'Gagal menghapus jurnal')
   461→    } finally {
   462→      setDeleting(false)
   463→    }
   464→  }
   465→
   466→  function handleEdit(id: string) {
   467→    setView('journal-create', { editId: id })
   468→  }
   469→
   470→  // ---------- Derived ----------
   471→  const showingFrom = limit === 0 ? 1 : (page - 1) * limit + 1
   472→  const showingTo = limit === 0 ? total : Math.min(page * limit, total)
   473→  const hasNext = page < totalPages
   474→  const hasPrev = page > 1
   475→
   476→  // Page numbers to render (compact)
   477→  const pageNumbers = useMemo(() => {
   478→    const out: (number | '...')[] = []
   479→    const total = totalPages
   480→    if (total <= 7) {
   481→      for (let i = 1; i <= total; i++) out.push(i)
   482→    } else {
   483→      out.push(1)
   484→      if (page > 3) out.push('...')
   485→      const start = Math.max(2, page - 1)
   486→      const end = Math.min(total - 1, page + 1)
   487→      for (let i = start; i <= end; i++) out.push(i)
   488→      if (page < total - 2) out.push('...')
   489→      out.push(total)
   490→    }
   491→    return out
   492→  }, [page, totalPages])
   493→
   494→  return (
   495→    <div className="space-y-4">
   496→      <PageHeader
   497→        title="Jurnal Transaksi"
   498→        subtitle="Daftar jurnal transaksi periode berjalan"
   499→        icon={<BookOpen className="h-5 w-5" />}
   500→        actions={
   501→          <>
   502→            <Button
   503→              variant="secondary"
   504→              className="neu-sm neu-pressable"
   505→              onClick={handleExportCSV}
   506→            >
   507→              <Download className="h-4 w-4" />
   508→              Export CSV
   509→            </Button>
   510→            <Button
   511→              className="neu-sm neu-pressable"
   512→              onClick={() => setView('journal-create')}
   513→            >
   514→              <Plus className="h-4 w-4" />
   515→              Input Jurnal
   516→            </Button>
   517→          </>
   518→        }
   519→      />
   520→
   521→      {/* Filters bar */}
   522→      <div className="rounded-2xl neu p-3 md:p-4">
   523→        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
   524→          {/* Global Search */}
   525→          <div className="lg:col-span-2 xl:col-span-1">
   526→            <label className="text-xs font-medium text-muted-foreground mb-1 block">
   527→              Pencarian
   528→            </label>
   529→            <div className="relative">
   530→              <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
   531→              <Input
   532→                placeholder="No Bukti / No Ref / Keterangan"
   533→                value={search}
   534→                onChange={(e) => setSearch(e.target.value)}
   535→                className="neu-inset-sm pl-9 border-0 shadow-none focus-visible:ring-0"
   536→              />
   537→            </div>
   538→          </div>
   539→
   540→          {/* Date From */}
   541→          <div>
   542→            <label className="text-xs font-medium text-muted-foreground mb-1 block">
   543→              Tanggal Dari
   544→            </label>
   545→            <Input
   546→              type="date"
   547→              value={dateFrom}
   548→              onChange={(e) => {
   549→                setDateFrom(e.target.value)
   550→                setPage(1)
   551→              }}
   552→              className="neu-inset-sm border-0 shadow-none focus-visible:ring-0"
   553→            />
   554→          </div>
   555→
   556→          {/* Date To */}
   557→          <div>
   558→            <label className="text-xs font-medium text-muted-foreground mb-1 block">
   559→              Tanggal Sampai
   560→            </label>
   561→            <Input
   562→              type="date"
   563→              value={dateTo}
   564→              onChange={(e) => {
   565→                setDateTo(e.target.value)
   566→                setPage(1)
   567→              }}
   568→              className="neu-inset-sm border-0 shadow-none focus-visible:ring-0"
   569→            />
   570→          </div>
   571→
   572→          {/* Pihak Terkait */}
   573→          <div>
   574→            <label className="text-xs font-medium text-muted-foreground mb-1 block">
   575→              Pihak Terkait
   576→            </label>
   577→            <Select
   578→              value={pihakTerkaitId}
   579→              onValueChange={(v) => {
   580→                setPihakTerkaitId(v)
   581→                setPage(1)
   582→              }}
   583→            >
   584→              <SelectTrigger className="neu-inset-sm border-0 shadow-none w-full">
   585→                <SelectValue placeholder="Semua" />
   586→              </SelectTrigger>
   587→              <SelectContent>
   588→                <SelectItem value="all">Semua</SelectItem>
   589→                {relatedParties.map((rp) => (
   590→                  <SelectItem key={rp.id} value={rp.id}>
   591→                    {rp.nama}
   592→                  </SelectItem>
   593→                ))}
   594→              </SelectContent>
   595→            </Select>
   596→          </div>
   597→
   598→          {/* Klasifikasi Fiskal */}
   599→          <div>
   600→            <label className="text-xs font-medium text-muted-foreground mb-1 block">
   601→              Klasifikasi Fiskal
   602→            </label>
   603→            <Select
   604→              value={klasifikasiFiskal}
   605→              onValueChange={(v) => {
   606→                setKlasifikasiFiskal(v)
   607→                setPage(1)
   608→              }}
   609→            >
   610→              <SelectTrigger className="neu-inset-sm border-0 shadow-none w-full">
   611→                <SelectValue placeholder="Semua" />
   612→              </SelectTrigger>
   613→              <SelectContent>
   614→                <SelectItem value="all">Semua</SelectItem>
   615→                {KLASIFIKASI_OPTIONS.map((k) => (
   616→                  <SelectItem key={k} value={k}>
   617→                    {k}
   618→                  </SelectItem>
   619→                ))}
   620→              </SelectContent>
   621→            </Select>
   622→          </div>
   623→
   624→          {/* Tipe Transaksi */}
   625→          <div>
   626→            <label className="text-xs font-medium text-muted-foreground mb-1 block">
   627→              Tipe Transaksi
   628→            </label>
   629→            <Select
   630→              value={transactionType}
   631→              onValueChange={(v) => {
   632→                setTransactionType(v)
   633→                setPage(1)
   634→              }}
   635→            >
   636→              <SelectTrigger className="neu-inset-sm border-0 shadow-none w-full">
   637→                <SelectValue placeholder="Semua" />
   638→              </SelectTrigger>
   639→              <SelectContent>
   640→                <SelectItem value="all">Semua</SelectItem>
   641→                {TYPE_OPTIONS.map((t) => (
   642→                  <SelectItem key={t.value} value={t.value}>
   643→                    {t.label}
   644→                  </SelectItem>
   645→                ))}
   646→              </SelectContent>
   647→            </Select>
   648→          </div>
   649→
   650→          {/* Status */}
   651→          <div>
   652→            <label className="text-xs font-medium text-muted-foreground mb-1 block">
   653→              Status
   654→            </label>
   655→            <Select
   656→              value={status}
   657→              onValueChange={(v) => {
   658→                setStatus(v)
   659→                setPage(1)
   660→              }}
   661→            >
   662→              <SelectTrigger className="neu-inset-sm border-0 shadow-none w-full">
   663→                <SelectValue placeholder="Semua" />
   664→              </SelectTrigger>
   665→              <SelectContent>
   666→                <SelectItem value="all">Semua</SelectItem>
   667→                <SelectItem value="Balanced">Balanced</SelectItem>
   668→                <SelectItem value="Unbalanced">Unbalanced</SelectItem>
   669→              </SelectContent>
   670→            </Select>
   671→          </div>
   672→
   673→          {/* Sort field + dir */}
   674→          <div>
   675→            <label className="text-xs font-medium text-muted-foreground mb-1 block">
   676→              Urutkan
   677→            </label>
   678→            <div className="flex gap-2">
   679→              <Select
   680→                value={sortField}
   681→                onValueChange={(v) => setSortField(v)}
   682→              >
   683→                <SelectTrigger className="neu-inset-sm border-0 shadow-none w-full">
   684→                  <SelectValue />
   685→                </SelectTrigger>
   686→                <SelectContent>
   687→                  {SORT_OPTIONS.map((s) => (
   688→                    <SelectItem key={s.value} value={s.value}>
   689→                      {s.label}
   690→                    </SelectItem>
   691→                  ))}
   692→                </SelectContent>
   693→              </Select>
   694→              <Button
   695→                type="button"
   696→                variant="secondary"
   697→                size="icon"
   698→                className="neu-sm neu-pressable shrink-0"
   699→                onClick={handleSortToggle}
   700→                title={sortDir === 'asc' ? 'Naik (ASC)' : 'Turun (DESC)'}
   701→              >
   702→                {sortDir === 'asc' ? (
   703→                  <ArrowUp className="h-4 w-4" />
   704→                ) : (
   705→                  <ArrowDown className="h-4 w-4" />
   706→                )}
   707→              </Button>
   708→            </div>
   709→          </div>
   710→        </div>
   711→
   712→        <div className="flex items-center justify-end mt-3">
   713→          <Button
   714→            variant="ghost"
   715→            size="sm"
   716→            className="neu-sm neu-pressable"
   717→            onClick={handleReset}
   718→          >
   719→            <RotateCcw className="h-4 w-4" />
   720→            Reset
   721→          </Button>
   722→        </div>
   723→      </div>
   724→
   725→      {/* Summary bar */}
   726→      <div className="rounded-2xl neu p-3 md:p-4 grid grid-cols-2 sm:grid-cols-3 gap-3">
   727→        <div>
   728→          <p className="text-xs text-muted-foreground">Total Debet (filter)</p>
   729→          <p className="text-base sm:text-lg font-bold tnum text-foreground">
   730→            Rp {formatCurrency(totals.totalDebet)}
   731→          </p>
   732→        </div>
   733→        <div>
   734→          <p className="text-xs text-muted-foreground">Total Kredit (filter)</p>
   735→          <p className="text-base sm:text-lg font-bold tnum text-foreground">
   736→            Rp {formatCurrency(totals.totalKredit)}
   737→          </p>
   738→        </div>
   739→        <div className="col-span-2 sm:col-span-1">
   740→          <p className="text-xs text-muted-foreground">Selisih</p>
   741→          <p
   742→            className={cn(
   743→              'text-base sm:text-lg font-bold tnum',
   744→              Math.abs(totals.totalDebet - totals.totalKredit) < 1
   745→                ? 'text-primary'
   746→                : 'text-destructive'
   747→            )}
   748→          >
   749→            Rp {formatCurrency(totals.totalDebet - totals.totalKredit)}
   750→          </p>
   751→        </div>
   752→      </div>
   753→
   754→      {/* Table */}
   755→      <div className="rounded-2xl neu overflow-hidden">
   756→        <div className="overflow-x-auto neu-scroll">
   757→          <Table>
   758→            <TableHeader>
   759→              <TableRow className="bg-muted/40 hover:bg-muted/40">
   760→                <TableHead className="sticky left-0 z-10 bg-muted/40">Tanggal</TableHead>
   761→                <TableHead>No Bukti</TableHead>
   762→                <TableHead>Pihak Terkait</TableHead>
   763→                <TableHead>No Ref</TableHead>
   764→                <TableHead>Klasifikasi</TableHead>
   765→                <TableHead>Tipe</TableHead>
   766→                <TableHead className="text-right">Debet</TableHead>
   767→                <TableHead className="text-right">Kredit</TableHead>
   768→                <TableHead>Status</TableHead>
   769→                <TableHead className="text-right">Aksi</TableHead>
   770→              </TableRow>
   771→            </TableHeader>
   772→            <TableBody>
   773→              {loading ? (
   774→                Array.from({ length: 6 }).map((_, i) => (
   775→                  <TableRow key={`sk-${i}`}>
   776→                    {Array.from({ length: 10 }).map((__, j) => (
   777→                      <TableCell key={`skc-${i}-${j}`}>
   778→                        <Skeleton className="h-4 w-full max-w-[120px]" />
   779→                      </TableCell>
   780→                    ))}
   781→                  </TableRow>
   782→                ))
   783→              ) : data.length === 0 ? (
   784→                <TableRow>
   785→                  <TableCell colSpan={10} className="text-center py-12">
   786→                    <div className="flex flex-col items-center gap-2 text-muted-foreground">
   787→                      <BookOpen className="h-8 w-8 opacity-50" />
   788→                      <p className="text-sm">Belum ada jurnal yang cocok dengan filter.</p>
   789→                      <Button
   790→                        variant="ghost"
   791→                        size="sm"
   792→                        className="neu-sm neu-pressable mt-1"
   793→                        onClick={() => setView('journal-create')}
   794→                      >
   795→                        <Plus className="h-4 w-4" />
   796→                        Input Jurnal Baru
   797→                      </Button>
   798→                    </div>
   799→                  </TableCell>
   800→                </TableRow>
   801→              ) : (
   802→                data.map((j) => (
   803→                  <TableRow key={j.id} className="text-sm">
   804→                    <TableCell className="whitespace-nowrap">
   805→                      {formatDate(j.tanggal)}
   806→                    </TableCell>
   807→                    <TableCell className="font-medium">{j.noBukti}</TableCell>
   808→                    <TableCell className="max-w-[180px] truncate">
   809→                      {j.pihakTerkait?.nama ?? '—'}
   810→                    </TableCell>
   811→                    <TableCell className="text-muted-foreground">
   812→                      {j.noRef ?? '—'}
   813→                    </TableCell>
   814→                    <TableCell>
   815→                      <Badge
   816→                        variant="outline"
   817→                        className={cn('text-[10px]', klasifikasiBadgeVariant(j.klasifikasiFiskal))}
   818→                      >
   819→                        {j.klasifikasiFiskal}
   820→                      </Badge>
   821→                    </TableCell>
   822→                    <TableCell>
   823→                      <Badge
   824→                        variant="outline"
   825→                        className={cn('text-[10px]', typeBadgeClass(j.transactionType))}
   826→                      >
   827→                        {j.transactionType}
   828→                      </Badge>
   829→                    </TableCell>
   830→                    <TableCell className="text-right tnum">
   831→                      {formatCurrency(j.totalDebet)}
   832→                    </TableCell>
   833→                    <TableCell className="text-right tnum">
   834→                      {formatCurrency(j.totalKredit)}
   835→                    </TableCell>
   836→                    <TableCell>
   837→                      <Badge
   838→                        variant="outline"
   839→                        className={cn('text-[10px]', statusBadgeClass(j.status))}
   840→                      >
   841→                        {j.status}
   842→                      </Badge>
   843→                    </TableCell>
   844→                    <TableCell className="text-right">
   845→                      <div className="flex items-center justify-end gap-1">
   846→                        {j.attachmentCount > 0 && (
   847→                          <span
   848→                            title={`${j.attachmentCount} lampiran`}
   849→                            className="inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground"
   850→                          >
   851→                            <Paperclip className="h-3.5 w-3.5" />
   852→                          </span>
   853→                        )}
   854→                        <DropdownMenu>
   855→                          <DropdownMenuTrigger asChild>
   856→                            <Button
   857→                              variant="ghost"
   858→                              size="icon"
   859→                              className="h-7 w-7"
   860→                              aria-label="Aksi"
   861→                            >
   862→                              <MoreHorizontal className="h-4 w-4" />
   863→                            </Button>
   864→                          </DropdownMenuTrigger>
   865→                          <DropdownMenuContent align="end">
   866→                            <DropdownMenuItem onClick={() => openDetail(j.id)}>
   867→                              <Eye className="h-4 w-4" />
   868→                              Lihat Detail
   869→                            </DropdownMenuItem>
   870→                            <DropdownMenuItem onClick={() => handleEdit(j.id)}>
   871→                              <Pencil className="h-4 w-4" />
   872→                              Edit
   873→                            </DropdownMenuItem>
   874→                            <DropdownMenuSeparator />
   875→                            <DropdownMenuItem
   876→                              variant="destructive"
   877→                              onClick={() => setDeleteId(j.id)}
   878→                            >
   879→                              <Trash2 className="h-4 w-4" />
   880→                              Hapus
   881→                            </DropdownMenuItem>
   882→                          </DropdownMenuContent>
   883→                        </DropdownMenu>
   884→                      </div>
   885→                    </TableCell>
   886→                  </TableRow>
   887→                ))
   888→              )}
   889→            </TableBody>
   890→          </Table>
   891→        </div>
   892→
   893→        {/* Pagination */}
   894→        <div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 p-3 border-t">
   895→          <div className="flex items-center gap-2">
   896→            <span className="text-xs text-muted-foreground">Baris per halaman</span>
   897→            <Select
   898→              value={String(limit)}
   899→              onValueChange={(v) => {
   900→                setLimit(Number(v))
   901→                setPage(1)
   902→              }}
   903→            >
   904→              <SelectTrigger className="neu-inset-sm border-0 shadow-none h-8 w-[100px]">
   905→                <SelectValue />
   906→              </SelectTrigger>
   907→              <SelectContent>
   908→                {LIMIT_OPTIONS.map((o) => (
   909→                  <SelectItem key={o.value} value={o.value}>
   910→                    {o.label}
   911→                  </SelectItem>
   912→                ))}
   913→              </SelectContent>
   914→            </Select>
   915→          </div>
   916→
   917→          <div className="text-xs text-muted-foreground text-center sm:text-right">
   918→            {total === 0
   919→              ? 'Tidak ada data'
   920→              : `Menampilkan ${showingFrom}–${showingTo} dari ${total}`}
   921→          </div>
   922→
   923→          <div className="flex items-center justify-center gap-1">
   924→            <Button
   925→              variant="ghost"
   926→              size="icon"
   927→              className="h-8 w-8"
   928→              disabled={!hasPrev}
   929→              onClick={() => setPage((p) => Math.max(1, p - 1))}
   930→              aria-label="Halaman sebelumnya"
   931→            >
   932→              <ChevronLeft className="h-4 w-4" />
   933→            </Button>
   934→            {pageNumbers.map((p, i) =>
   935→              p === '...' ? (
   936→                <span
   937→                  key={`e-${i}`}
   938→                  className="px-2 text-xs text-muted-foreground"
   939→                >
   940→                  …
   941→                </span>
   942→              ) : (
   943→                <Button
   944→                  key={p}
   945→                  variant={p === page ? 'default' : 'ghost'}
   946→                  size="icon"
   947→                  className={cn(
   948→                    'h-8 w-8 text-xs',
   949→                    p === page && 'neu-pressable'
   950→                  )}
   951→                  onClick={() => setPage(p)}
   952→                >
   953→                  {p}
   954→                </Button>
   955→              )
   956→            )}
   957→            <Button
   958→              variant="ghost"
   959→              size="icon"
   960→              className="h-8 w-8"
   961→              disabled={!hasNext}
   962→              onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
   963→              aria-label="Halaman berikutnya"
   964→            >
   965→              <ChevronRight className="h-4 w-4" />
   966→            </Button>
   967→          </div>
   968→        </div>
   969→      </div>
   970→
   971→      {/* Detail Dialog */}
   972→      <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
   973→        <DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto neu-scroll">
   974→          <DialogHeader>
   975→            <DialogTitle className="flex items-center gap-2">
   976→              <FileText className="h-5 w-5 text-primary" />
   977→              Detail Jurnal
   978→            </DialogTitle>
   979→            <DialogDescription>
   980→              {detailData
   981→                ? `${detailData.noBukti} — ${formatDate(detailData.tanggal)}`
   982→                : 'Memuat…'}
   983→            </DialogDescription>
   984→          </DialogHeader>
   985→
   986→          {detailLoading || !detailData ? (
   987→            <div className="space-y-3 py-2">
   988→              <Skeleton className="h-6 w-full" />
   989→              <Skeleton className="h-6 w-full" />
   990→              <Skeleton className="h-6 w-full" />
   991→            </div>
   992→          ) : (
   993→            <div className="space-y-4">
   994→              {/* Header info */}
   995→              <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
   996→                <InfoCell label="Tanggal" value={formatDate(detailData.tanggal)} />
   997→                <InfoCell label="No Bukti" value={detailData.noBukti} />
   998→                <InfoCell
   999→                  label="No Ref"
  1000→                  value={detailData.noRef ?? '—'}
  1001→                />
  1002→                <InfoCell
  1003→                  label="Pihak Terkait"
  1004→                  value={detailData.pihakTerkait?.nama ?? '—'}
  1005→                />
  1006→                <InfoCell
  1007→                  label="Jatuh Tempo"
  1008→                  value={
  1009→                    detailData.tanggalJatuhTempo
  1010→                      ? formatDate(detailData.tanggalJatuhTempo)
  1011→                      : '—'
  1012→                  }
  1013→                />
  1014→                <InfoCell
  1015→                  label="Status"
  1016→                  value={
  1017→                    <Badge
  1018→                      variant="outline"
  1019→                      className={cn('text-[10px]', statusBadgeClass(detailData.status))}
  1020→                    >
  1021→                      {detailData.status}
  1022→                    </Badge>
  1023→                  }
  1024→                />
  1025→                <InfoCell
  1026→                  label="Klasifikasi Fiskal"
  1027→                  value={
  1028→                    <Badge
  1029→                      variant="outline"
  1030→                      className={cn(
  1031→                        'text-[10px]',
  1032→                        klasifikasiBadgeVariant(detailData.klasifikasiFiskal)
  1033→                      )}
  1034→                    >
  1035→                      {detailData.klasifikasiFiskal}
  1036→                    </Badge>
  1037→                  }
  1038→                />
  1039→                <InfoCell
  1040→                  label="Tipe Transaksi"
  1041→                  value={
  1042→                    <Badge
  1043→                      variant="outline"
  1044→                      className={cn(
  1045→                        'text-[10px]',
  1046→                        typeBadgeClass(detailData.transactionType)
  1047→                      )}
  1048→                    >
  1049→                      {detailData.transactionType}
  1050→                    </Badge>
  1051→                  }
  1052→                />
  1053→              </div>
  1054→
  1055→              {detailData.keterangan && (
  1056→                <div className="rounded-xl neu-inset-sm p-3 text-sm">
  1057→                  <p className="text-xs text-muted-foreground mb-1">Keterangan</p>
  1058→                  <p className="text-foreground">{detailData.keterangan}</p>
  1059→                </div>
  1060→              )}
  1061→
  1062→              {/* Details table */}
  1063→              <div className="rounded-xl neu overflow-hidden">
  1064→                <div className="overflow-x-auto neu-scroll">
  1065→                  <Table>
  1066→                    <TableHeader>
  1067→                      <TableRow className="bg-muted/40 hover:bg-muted/40">
  1068→                        <TableHead>Akun</TableHead>
  1069→                        <TableHead className="text-right">Debet</TableHead>
  1070→                        <TableHead className="text-right">Kredit</TableHead>
  1071→                        <TableHead>Keterangan</TableHead>
  1072→                      </TableRow>
  1073→                    </TableHeader>
  1074→                    <TableBody>
  1075→                      {detailData.details.map((d) => (
  1076→                        <TableRow key={d.id} className="text-sm">
  1077→                          <TableCell>
  1078→                            <div className="font-medium">
  1079→                              {d.coa?.kodeAkun ?? '—'} — {d.coa?.namaAkun ?? 'Akun'}
  1080→                            </div>
  1081→                            <div className="text-xs text-muted-foreground">
  1082→                              {d.coa?.jenisAkun}
  1083→                            </div>
  1084→                          </TableCell>
  1085→                          <TableCell className="text-right tnum">
  1086→                            {d.debet ? formatCurrency(d.debet) : '—'}
  1087→                          </TableCell>
  1088→                          <TableCell className="text-right tnum">
  1089→                            {d.kredit ? formatCurrency(d.kredit) : '—'}
  1090→                          </TableCell>
  1091→                          <TableCell className="text-muted-foreground">
  1092→                            {d.keterangan ?? '—'}
  1093→                          </TableCell>
  1094→                        </TableRow>
  1095→                      ))}
  1096→                    </TableBody>
  1097→                    <tfoot>
  1098→                      <tr className="bg-muted/40 border-t font-medium">
  1099→                        <td className="p-2">Total</td>
  1100→                        <td className="p-2 text-right tnum">
  1101→                          {formatCurrency(detailData.totalDebet)}
  1102→                        </td>
  1103→                        <td className="p-2 text-right tnum">
  1104→                          {formatCurrency(detailData.totalKredit)}
  1105→                        </td>
  1106→                        <td className="p-2"></td>
  1107→                      </tr>
  1108→                    </tfoot>
  1109→                  </Table>
  1110→                </div>
  1111→              </div>
  1112→
  1113→              {/* Attachments */}
  1114→              {detailData.attachments.length > 0 && (
  1115→                <div className="space-y-2">
  1116→                  <p className="text-sm font-medium text-foreground">
  1117→                    Lampiran ({detailData.attachments.length})
  1118→                  </p>
  1119→                  <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
  1120→                    {detailData.attachments.map((a) => (
  1121→                      <a
  1122→                        key={a.id}
  1123→                        href={a.fileData}
  1124→                        download={a.fileName}
  1125→                        className="flex items-center gap-3 rounded-xl neu-sm neu-hover p-2.5 text-sm hover:text-primary"
  1126→                      >
  1127→                        {a.fileType.startsWith('image/') ? (
  1128→                          <img
  1129→                            src={a.fileData}
  1130→                            alt={a.fileName}
  1131→                            className="h-10 w-10 object-cover rounded-md shrink-0"
  1132→                          />
  1133→                        ) : (
  1134→                          <div className="h-10 w-10 rounded-md bg-muted flex items-center justify-center shrink-0">
  1135→                            <FileText className="h-5 w-5 text-muted-foreground" />
  1136→                          </div>
  1137→                        )}
  1138→                        <div className="min-w-0 flex-1">
  1139→                          <p className="truncate font-medium">{a.fileName}</p>
  1140→                          <p className="text-xs text-muted-foreground">
  1141→                            {formatBytes(a.fileSize)}
  1142→                          </p>
  1143→                        </div>
  1144→                        <Download className="h-4 w-4 shrink-0" />
  1145→                      </a>
  1146→                    ))}
  1147→                  </div>
  1148→                </div>
  1149→              )}
  1150→
  1151→              <div className="flex justify-end gap-2 pt-2">
  1152→                <Button
  1153→                  variant="secondary"
  1154→                  className="neu-sm neu-pressable"
  1155→                  onClick={() => handleEdit(detailData.id)}
  1156→                >
  1157→                  <Pencil className="h-4 w-4" />
  1158→                  Edit
  1159→                </Button>
  1160→                <Button
  1161→                  variant="ghost"
  1162→                  className="neu-sm neu-pressable"
  1163→                  onClick={() => setDetailOpen(false)}
  1164→                >
  1165→                  Tutup
  1166→                </Button>
  1167→              </div>
  1168→            </div>
  1169→          )}
  1170→        </DialogContent>
  1171→      </Dialog>
  1172→
  1173→      {/* Delete confirm */}
  1174→      <AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
  1175→        <AlertDialogContent>
  1176→          <AlertDialogHeader>
  1177→            <AlertDialogTitle>Hapus Jurnal?</AlertDialogTitle>
  1178→            <AlertDialogDescription>
  1179→              Tindakan ini tidak dapat dibatalkan. Seluruh detail dan lampiran
  1180→              jurnal akan ikut terhapus.
  1181→            </AlertDialogDescription>
  1182→          </AlertDialogHeader>
  1183→          <AlertDialogFooter>
  1184→            <AlertDialogCancel disabled={deleting} className="neu-sm">
  1185→              Batal
  1186→            </AlertDialogCancel>
  1187→            <AlertDialogAction
  1188→              onClick={handleConfirmDelete}
  1189→              disabled={deleting}
  1190→              className="bg-destructive text-white hover:bg-destructive/90"
  1191→            >
  1192→              {deleting ? 'Menghapus…' : 'Hapus'}
  1193→            </AlertDialogAction>
  1194→          </AlertDialogFooter>
  1195→        </AlertDialogContent>
  1196→      </AlertDialog>
  1197→    </div>
  1198→  )
  1199→}
  1200→
  1201→function InfoCell({
  1202→  label,
  1203→  value,
  1204→}: {
  1205→  label: string
  1206→  value: React.ReactNode
  1207→}) {
  1208→  return (
  1209→    <div className="rounded-xl neu-inset-sm p-2.5">
  1210→      <p className="text-xs text-muted-foreground">{label}</p>
  1211→      <div className="text-sm font-medium text-foreground mt-0.5">{value}</div>
  1212→    </div>
  1213→  )
  1214→}
  1215→