think
16px
820px

New Document experience redesign — 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: Split document creation into two clear entry points (Upload / Write), make the upload modal drag-drop-first with an autofilled title and a readable multi-suggestion folder box, and reframe the write flow so authoring goes straight to the editor and metadata + filing (Save = PDF-but-editable / Save as draft = HTML) are collected in a new Save modal with a deferred Document ID.

Architecture: Frontend-only Phase A (a reusable FolderSuggestBox, a redesigned upload modal, a write-only modal, split toolbar). Phase B adds the write reframe (direct-to-editor unfiled drafts, a SaveDocumentModal, single editor Save button) plus two small backend additions (assign-Document-ID-to-existing-doc endpoint; an editable flag on the documents list). Reuses the existing TipTap editor, the finalize→PDF pipeline (already takes folderId), versions-by-MIME, and the semantic suggest endpoint — not a rebuild.

Tech Stack: React + Carbon (web/src), TanStack Query, openapi-fetch typed client, react-i18next (en/id parity tsc-enforced); Go modular monolith (go/internal, chi, pgx), api/openapi.yamlnpm run gen:api.


Working discipline (applies to EVERY task)

  • NEVER run go test (test DSN = live demo Postgres). Verify Go via cd go && go build ./... && go vet ./....
  • Verify frontend via cd web && npx tsc --noEmit && npx vite build. Run npm run gen:api after any api/openapi.yaml change. i18n en/id parity is tsc-enforced — add both locales.
  • Deploy only from repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build. After every deploy assert /me enabled_modules is ['ai','correspondence','esign','semantic','watermarking'] and the demo is intact; clean up any test docs/folders you create.
  • Commit per task locally on main. Do NOT push unless asked.
  • FolderSuggestBox must degrade gracefully when semantic is unlicensed (no AI rows; current-folder + browse remain).
  • Deferred Document ID must reject re-assignment (immutability).
  • Upload the plan/spec .md via curl -F "file=@<path>.md" https://x056.think.val.id/upload if you modify them.

Post-deploy assertion snippet (reuse verbatim)

TOKEN=$(curl -s -XPOST localhost:38080/api/v1/auth/dev-login -H 'content-type: application/json' -d '{"email":"admin@obscura.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
curl -s localhost:38080/api/v1/me -H "authorization: Bearer $TOKEN" | python3 -c 'import sys,json;print(sorted(json.load(sys.stdin)["enabled_modules"]))'

File Structure

Phase A (frontend only):
- Create web/src/features/documents/FolderSuggestBox.tsx — the reusable folder-suggestions box (AI top-3 + current folder + browse; auto-run; degrades).
- Create web/src/features/documents/UploadDocumentModal.tsx — drag-drop-first upload modal that expands into the metadata form + FolderSuggestBox; autofills title.
- Modify web/src/features/documents/NewDocumentModal.tsx → strip to write-only (title/classification/id/customs → editor); no file, no mode toggle, no folder box.
- Modify web/src/features/documents/DocumentsPage.tsx — split the toolbar button into Upload Document + Write Document; the upload submit files into the selected folder.
- Modify web/src/i18n/locales/en.ts + id.ts — new keys (newDoc.uploadTitle, newDoc.writeTitle, drop-zone, folder-box strings).

Phase B (frontend + small backend):
- Modify web/src/features/documents/DocumentsPage.tsx — Write button creates an unfiled ID-less draft → navigates straight to the editor (drop the write-only modal).
- Create web/src/features/documents/SaveDocumentModal.tsx — collects title/classification/Document-ID/folder (reuses FolderSuggestBox); Save / Save as draft.
- Modify web/src/features/documents/TextEditorView.tsx — replace the two Save/Finalize buttons with one Save → SaveDocumentModal; wire Save (finalize+folder) and Save-as-draft (HTML+move).
- Modify web/src/api/files.ts + web/src/api/documents.tsassignDocumentId call + editable in the list mapping.
- Modify web/src/components/DocFlags.tsx + web/src/api/types.ts — add the editable flag/icon; version labels in the versions tab.
- Backend: go/internal/dms/app/service.go (AssignReference) + go/internal/dms/adapters/* (SetDocumentReference) + go/internal/httpapi/handlers_dms_* (assign-id handler) + handlers_dms_nav.go ListDocuments (editable_ids) + api/openapi.yaml.


PHASE A — Upload redesign + folder box + split toolbar (frontend only, shippable)

Task A1: i18n keys

Files:
- Modify: web/src/i18n/locales/en.ts (the newDoc.* block, ~lines 126-146)
- Modify: web/src/i18n/locales/id.ts (mirror)

  • [ ] Step 1: Add keys to en.ts inside the existing newDoc object (keep the existing keys; add these):
    uploadTitle: 'Upload document',
    writeTitle: 'Write document',
    dropHint: 'Drag a file here, or browse',
    dropBrowse: 'browse',
    titleFromFile: 'Title (from file name — edit if needed)',
    folderBoxTitle: 'Where should this go?',
    folderCurrent: 'Current folder',
    folderBrowse: 'Browse for another folder…',
    folderSuggesting: 'Finding folders…',
    folderMatch: '{{pct}}% match',
    folderNone: 'No suggestions — choose a folder',
  • [ ] Step 2: Add the SAME keys to id.ts (Indonesian) inside its newDoc object:
    uploadTitle: 'Unggah dokumen',
    writeTitle: 'Tulis dokumen',
    dropHint: 'Seret berkas ke sini, atau telusuri',
    dropBrowse: 'telusuri',
    titleFromFile: 'Judul (dari nama berkas — ubah bila perlu)',
    folderBoxTitle: 'Simpan ke mana?',
    folderCurrent: 'Folder saat ini',
    folderBrowse: 'Telusuri folder lain…',
    folderSuggesting: 'Mencari folder…',
    folderMatch: '{{pct}}% cocok',
    folderNone: 'Tidak ada saran — pilih folder',
  • [ ] Step 3: Verify + commit
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit
cd /home/efran/remote-development/obscura
git add web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts
git commit -m "feat(web): i18n keys for the New Document redesign"

Expected: tsc clean (parity enforced).


Task A2: FolderSuggestBox component

Files:
- Create: web/src/features/documents/FolderSuggestBox.tsx
- Reference: web/src/api/semantic.ts (useSuggestFolderFolderSuggestion[] {folderId,path,score}), web/src/lib/nav.ts (moduleEnabled), web/src/api/me.ts (useMe), web/src/features/documents/MoveModal.tsx (the browse tree, reused).

  • [ ] Step 1: Write the component
// A reusable "where should this go?" folder chooser: AI folder suggestions (semantic module),
// the current folder (always offered), and a Browse fallback (the existing MoveModal tree).
// Auto-runs the suggestion when its query settles; degrades to current-folder + browse when the
// semantic module is not licensed. Controlled: the parent owns the selected folder id + path.
import { useEffect, useState } from 'react'
import { RadioButtonGroup, RadioButton, InlineLoading, Button } from '@carbon/react'
import { useTranslation } from 'react-i18next'
import { useMe } from '@/api/me'
import { moduleEnabled } from '@/lib/nav'
import { useSuggestFolder, type FolderSuggestion } from '@/api/semantic'
import { MoveModal } from './MoveModal'

const CURRENT = '__current__'
const BROWSE = '__browse__'

export interface FolderChoice {
  folderId: string | null
  path: string
}

interface Props {
  // The suggestion query. For upload: {title, filename}; for write: {title, text}.
  query: { title?: string; text?: string; filename?: string }
  // Whether to run the suggestion at all (e.g. only once a file is chosen).
  enabled: boolean
  currentFolderId: string | null
  currentFolderPath: string
  value: FolderChoice
  onChange: (choice: FolderChoice) => void
}

export function FolderSuggestBox({ query, enabled, currentFolderId, currentFolderPath, value, onChange }: Props) {
  const { t } = useTranslation()
  const me = useMe()
  const semanticEnabled = moduleEnabled(me.data?.enabledModules, 'semantic')
  const suggest = useSuggestFolder()
  const [rows, setRows] = useState<FolderSuggestion[]>([])
  const [browseOpen, setBrowseOpen] = useState(false)

  // Serialize the query so the effect only re-runs when the meaningful inputs change.
  const qKey = `${query.title ?? ''}${query.text ?? ''}${query.filename ?? ''}`
  useEffect(() => {
    if (!enabled || !semanticEnabled) {
      setRows([])
      return
    }
    if (!query.title && !query.text && !query.filename) return
    let cancelled = false
    const handle = setTimeout(() => {
      suggest.mutate(
        { title: query.title, text: query.text, filename: query.filename },
        {
          onSuccess: (r) => {
            if (!cancelled) setRows(r)
          },
        },
      )
    }, 400)
    return () => {
      cancelled = true
      clearTimeout(handle)
    }
  }, [qKey, enabled, semanticEnabled]) // eslint-disable-line react-hooks/exhaustive-deps

  // Suggestions minus the current folder (it gets its own always-present row).
  const aiRows = rows.filter((r) => r.folderId !== currentFolderId)

  // The selected radio value is derived from the controlled folderId.
  const selectedValue =
    value.folderId === currentFolderId ? CURRENT : value.folderId ?? CURRENT

  const pick = (v: string) => {
    if (v === CURRENT) return onChange({ folderId: currentFolderId, path: currentFolderPath })
    if (v === BROWSE) return setBrowseOpen(true)
    const row = aiRows.find((r) => r.folderId === v)
    if (row) onChange({ folderId: row.folderId, path: row.path })
  }

  return (
    <div className="foldersuggest">
      <p className="foldersuggest__title">{t('newDoc.folderBoxTitle')}</p>
      {suggest.isPending && <InlineLoading description={t('newDoc.folderSuggesting')} />}
      <RadioButtonGroup
        name="folder-suggest"
        orientation="vertical"
        valueSelected={selectedValue}
        onChange={(v) => pick(String(v))}
      >
        {aiRows.map((r) => (
          <RadioButton
            key={r.folderId}
            id={`fs-${r.folderId}`}
            value={r.folderId}
            labelText={`${r.path}  ·  ${t('newDoc.folderMatch', { pct: Math.round(r.score * 100) })}`}
          />
        ))}
        <RadioButton
          id="fs-current"
          value={CURRENT}
          labelText={`${currentFolderPath || '/'}  ·  ${t('newDoc.folderCurrent')}`}
        />
        <RadioButton id="fs-browse" value={BROWSE} labelText={t('newDoc.folderBrowse')} />
      </RadioButtonGroup>
      {value.folderId !== currentFolderId && value.folderId !== null && !aiRows.some((r) => r.folderId === value.folderId) && (
        <p className="foldersuggest__picked">{value.path}</p>
      )}
      {browseOpen && (
        <MoveModal
          open
          itemName={t('newDoc.folderBoxTitle')}
          onClose={() => setBrowseOpen(false)}
          onSubmit={(targetFolderId) => {
            setBrowseOpen(false)
            onChange({ folderId: targetFolderId, path: targetFolderId ? '' : (currentFolderPath || '/') })
          }}
        />
      )}
    </div>
  )
}

Notes: MoveModal's onSubmit(targetFolderId: string | null) returns the chosen folder id (null = root); its Props are { open, itemName, excludeFolderId?, busy?, onSubmit, onClose } (see MoveModal.tsx). We don't have the browsed folder's path handy, so we store an empty path and let the caller display the id-based selection; this is acceptable (the box still files correctly). If a path label for browsed folders is wanted later, resolve it via useChildFolders.

  • [ ] Step 2: Add minimal styles — append to the documents stylesheet (find the file that styles .newdoc__field, e.g. web/src/features/documents/documents.css or the global web/src/styles/*; grep newdoc__field):
.foldersuggest { margin-top: 1rem; }
.foldersuggest__title { font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.25rem; }
.foldersuggest__picked { font-size: 0.75rem; color: var(--cds-text-secondary); margin-top: 0.25rem; word-break: break-all; }
  • [ ] Step 3: Verify + commit
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
cd /home/efran/remote-development/obscura
git add web/src/features/documents/FolderSuggestBox.tsx web/src/features/documents/*.css web/src/styles 2>/dev/null; git add -A
git commit -m "feat(web): reusable FolderSuggestBox (AI + current folder + browse, degrades)"

Expected: tsc + vite clean.


Task A3: UploadDocumentModal (drag-drop-first, autofill, folder box)

Files:
- Create: web/src/features/documents/UploadDocumentModal.tsx
- Reference: current NewDocumentModal.tsx (fields to carry over), ACCEPTED_UPLOAD_TYPES from @/api/files, useIdFormats, useActiveClassifications.

  • [ ] Step 1: Write the modal. It reuses the metadata fields from NewDocumentModal but: (1) shows a drop zone first; (2) reveals the form only once a file exists; (3) autofills the title from the filename (extension stripped); (4) uses FolderSuggestBox. Its onSubmit shape matches the parent's create+upload flow.
// Upload-document modal: drag-and-drop first, then the metadata form expands once a file is
// chosen. Title autofills from the file name. Filing uses FolderSuggestBox (default = current).
import { useEffect, useState } from 'react'
import { Dropdown, InlineNotification, Modal, TextInput, FileUploaderDropContainer } from '@carbon/react'
import { useTranslation } from 'react-i18next'
import { type Classification } from '@/lib/classification'
import { useActiveClassifications } from '@/api/classifications'
import { useIdFormats } from '@/api/docid'
import { ACCEPTED_UPLOAD_TYPES } from '@/api/files'
import { FolderSuggestBox, type FolderChoice } from './FolderSuggestBox'

const ID_NONE = '__none__'
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
const stripExt = (name: string) => name.replace(/\.[^./\\]+$/, '')

function extractCustoms(pattern: string): string[] {
  const re = /\{custom:([^}]+)\}/g
  const out: string[] = []
  const seen = new Set<string>()
  let m: RegExpExecArray | null
  while ((m = re.exec(pattern)) !== null) {
    const n = m[1]!.trim().toLowerCase()
    if (n && !seen.has(n)) { seen.add(n); out.push(n) }
  }
  return out
}

export interface UploadDocumentInput {
  title: string
  classification: Classification
  docType: string
  formatId: string
  customVars: Record<string, string>
  file: File
  folderId: string | null
}

interface Props {
  open: boolean
  busy?: boolean
  error?: string | null
  currentFolderId: string | null
  currentFolderPath: string
  onSubmit: (input: UploadDocumentInput) => void
  onClose: () => void
}

export function UploadDocumentModal({ open, busy, error, currentFolderId, currentFolderPath, onSubmit, onClose }: Props) {
  const { t } = useTranslation()
  const { data: formats = [] } = useIdFormats()
  const levels = useActiveClassifications()
  const CLASS_ITEMS = levels.map((c) => ({ id: c.code, label: c.label }))

  const [file, setFile] = useState<File | null>(null)
  const [title, setTitle] = useState('')
  const [classification, setClassification] = useState<Classification>('none')
  const [docType, setDocType] = useState('')
  const [formatId, setFormatId] = useState('')
  const [customVars, setCustomVars] = useState<Record<string, string>>({})
  const [folder, setFolder] = useState<FolderChoice>({ folderId: currentFolderId, path: currentFolderPath })

  useEffect(() => {
    if (open) {
      setFile(null); setTitle(''); setClassification('none'); setDocType(''); setCustomVars({})
      setFolder({ folderId: currentFolderId, path: currentFolderPath })
    }
  }, [open]) // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    if (!open) return
    const def = formats.find((f) => f.isDefault)
    setFormatId(def ? def.id : '')
  }, [open, formats])

  const onFile = (f: File | null) => {
    setFile(f)
    if (f && !title.trim()) setTitle(stripExt(f.name))
  }

  const formatItems = [{ id: ID_NONE, label: t('newDoc.noId') }, ...formats.map((f) => ({ id: f.id, label: `${f.name} · ${f.pattern}` }))]
  const selectedFormat = formatItems.find((i) => i.id === (formatId || ID_NONE)) ?? formatItems[0]
  const selectedPattern = formats.find((f) => f.id === formatId)?.pattern ?? ''
  const customNames = extractCustoms(selectedPattern)
  const customsFilled = customNames.every((n) => (customVars[n] ?? '').trim() !== '')

  const trimmed = title.trim()
  const canSubmit = !!file && !!trimmed && !busy && customsFilled
  const submit = () => {
    if (!canSubmit || !file) return
    const vars: Record<string, string> = {}
    for (const n of customNames) vars[n] = (customVars[n] ?? '').trim()
    onSubmit({ title: trimmed, classification, docType: docType.trim(), formatId, customVars: vars, file, folderId: folder.folderId })
  }

  return (
    <Modal
      open={open}
      size="sm"
      modalHeading={t('newDoc.uploadTitle')}
      primaryButtonText={t('newDoc.create')}
      secondaryButtonText={t('detail.cancel')}
      primaryButtonDisabled={!canSubmit}
      onRequestClose={onClose}
      onRequestSubmit={submit}
    >
      {error && (
        <InlineNotification kind="error" lowContrast title={t('newDoc.failed')} subtitle={error} hideCloseButton />
      )}
      {!file ? (
        <FileUploaderDropContainer
          labelText={t('newDoc.dropHint')}
          accept={ACCEPTED_UPLOAD_TYPES}
          multiple={false}
          onAddFiles={(_e, { addedFiles }) => onFile(addedFiles?.[0] ?? null)}
        />
      ) : (
        <>
          <div className="newdoc__file-chip">
            <span className="newdoc__file-name">{file.name}</span>
            <button type="button" className="newdoc__file-remove" onClick={() => setFile(null)}></button>
          </div>
          <TextInput
            id="upload-doc-title"
            labelText={t('newDoc.titleFromFile')}
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            data-modal-primary-focus
          />
          <div className="newdoc__field">
            <Dropdown
              id="upload-doc-classification"
              titleText={t('newDoc.classification')}
              label={CLASS_ITEMS.find((i) => i.id === classification)?.label ?? classification}
              items={CLASS_ITEMS}
              selectedItem={CLASS_ITEMS.find((i) => i.id === classification) ?? null}
              itemToString={(i) => i?.label ?? ''}
              onChange={({ selectedItem }) => selectedItem && setClassification(selectedItem.id)}
            />
          </div>
          <div className="newdoc__field">
            <TextInput id="upload-doc-type" labelText={t('newDoc.docType')} placeholder={t('newDoc.docTypePlaceholder')} value={docType} onChange={(e) => setDocType(e.target.value)} />
          </div>
          <div className="newdoc__field">
            <Dropdown
              id="upload-doc-format"
              titleText={t('newDoc.format')}
              helperText={t('newDoc.formatHint')}
              label={selectedFormat?.label ?? ''}
              items={formatItems}
              selectedItem={selectedFormat}
              itemToString={(i) => i?.label ?? ''}
              onChange={({ selectedItem }) => setFormatId(selectedItem && selectedItem.id !== ID_NONE ? selectedItem.id : '')}
            />
          </div>
          {customNames.map((name) => (
            <div className="newdoc__field" key={name}>
              <TextInput id={`upload-doc-custom-${name}`} labelText={titleCase(name)} placeholder={t('newDoc.customHint', { name })} value={customVars[name] ?? ''} onChange={(e) => setCustomVars((p) => ({ ...p, [name]: e.target.value }))} />
            </div>
          ))}
          <FolderSuggestBox
            query={{ title, filename: file.name }}
            enabled={!!file}
            currentFolderId={currentFolderId}
            currentFolderPath={currentFolderPath}
            value={folder}
            onChange={setFolder}
          />
        </>
      )}
    </Modal>
  )
}
  • [ ] Step 2: Styles — append to the same CSS file as A2:
.newdoc__file-chip { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.5rem 0.75rem; background: var(--cds-layer); margin-bottom: 0.5rem; }
.newdoc__file-name { word-break: break-all; }
.newdoc__file-remove { background: none; border: 0; cursor: pointer; color: var(--cds-text-secondary); }
  • [ ] Step 3: Verify + commit
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
cd /home/efran/remote-development/obscura
git add -A
git commit -m "feat(web): drag-drop-first UploadDocumentModal with title autofill + folder box"

Task A4: Strip NewDocumentModal to write-only

Files:
- Modify: web/src/features/documents/NewDocumentModal.tsx

  • [ ] Step 1: Rewrite the modal to remove the mode toggle, the file uploader, and the semantic chip (all now handled by UploadDocumentModal). It collects only title/classification/docType/format/customs and always submits mode: 'text'. Replace the whole file body with:
// "Write document" modal: collects a title, classification and optional Document ID for a new
// in-app authored document, then hands off to the editor (the caller navigates). Upload lives in
// UploadDocumentModal. (Phase B replaces this with a direct-to-editor + Save-modal flow.)
import { useEffect, useState } from 'react'
import { Dropdown, InlineNotification, Modal, TextInput } from '@carbon/react'
import { useTranslation } from 'react-i18next'
import { type Classification } from '@/lib/classification'
import { useActiveClassifications } from '@/api/classifications'
import { useIdFormats } from '@/api/docid'

const ID_NONE = '__none__'
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)

export interface NewDocumentInput {
  title: string
  classification: Classification
  docType: string
  formatId: string
  customVars: Record<string, string>
}

function extractCustoms(pattern: string): string[] {
  const re = /\{custom:([^}]+)\}/g
  const out: string[] = []
  const seen = new Set<string>()
  let m: RegExpExecArray | null
  while ((m = re.exec(pattern)) !== null) {
    const n = m[1]!.trim().toLowerCase()
    if (n && !seen.has(n)) { seen.add(n); out.push(n) }
  }
  return out
}

interface Props {
  open: boolean
  busy?: boolean
  error?: string | null
  onSubmit: (input: NewDocumentInput) => void
  onClose: () => void
}

export function NewDocumentModal({ open, busy, error, onSubmit, onClose }: Props) {
  const { t } = useTranslation()
  const { data: formats = [] } = useIdFormats()
  const levels = useActiveClassifications()
  const CLASS_ITEMS = levels.map((c) => ({ id: c.code, label: c.label }))
  const [title, setTitle] = useState('')
  const [classification, setClassification] = useState<Classification>('none')
  const [docType, setDocType] = useState('')
  const [formatId, setFormatId] = useState('')
  const [customVars, setCustomVars] = useState<Record<string, string>>({})

  useEffect(() => {
    if (open) { setTitle(''); setClassification('none'); setDocType(''); setCustomVars({}) }
  }, [open]) // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (!open) return
    const def = formats.find((f) => f.isDefault)
    setFormatId(def ? def.id : '')
  }, [open, formats])

  const formatItems = [{ id: ID_NONE, label: t('newDoc.noId') }, ...formats.map((f) => ({ id: f.id, label: `${f.name} · ${f.pattern}` }))]
  const selectedFormat = formatItems.find((i) => i.id === (formatId || ID_NONE)) ?? formatItems[0]
  const selectedPattern = formats.find((f) => f.id === formatId)?.pattern ?? ''
  const customNames = extractCustoms(selectedPattern)
  const customsFilled = customNames.every((n) => (customVars[n] ?? '').trim() !== '')

  const trimmed = title.trim()
  const submit = () => {
    if (!trimmed || busy || !customsFilled) return
    const vars: Record<string, string> = {}
    for (const n of customNames) vars[n] = (customVars[n] ?? '').trim()
    onSubmit({ title: trimmed, classification, docType: docType.trim(), formatId, customVars: vars })
  }

  return (
    <Modal
      open={open}
      size="sm"
      modalHeading={t('newDoc.writeTitle')}
      primaryButtonText={t('newDoc.create')}
      secondaryButtonText={t('detail.cancel')}
      primaryButtonDisabled={!trimmed || busy || !customsFilled}
      onRequestClose={onClose}
      onRequestSubmit={submit}
    >
      {error && <InlineNotification kind="error" lowContrast title={t('newDoc.failed')} subtitle={error} hideCloseButton />}
      <TextInput id="new-doc-title" labelText={t('newDoc.titleLabel')} placeholder={t('newDoc.titlePlaceholder')} value={title}
        onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit() }} data-modal-primary-focus />
      <div className="newdoc__field">
        <Dropdown id="new-doc-classification" titleText={t('newDoc.classification')}
          label={CLASS_ITEMS.find((i) => i.id === classification)?.label ?? classification}
          items={CLASS_ITEMS} selectedItem={CLASS_ITEMS.find((i) => i.id === classification) ?? null}
          itemToString={(i) => i?.label ?? ''} onChange={({ selectedItem }) => selectedItem && setClassification(selectedItem.id)} />
      </div>
      <div className="newdoc__field">
        <TextInput id="new-doc-type" labelText={t('newDoc.docType')} placeholder={t('newDoc.docTypePlaceholder')} value={docType} onChange={(e) => setDocType(e.target.value)} />
      </div>
      <div className="newdoc__field">
        <Dropdown id="new-doc-format" titleText={t('newDoc.format')} helperText={t('newDoc.formatHint')} label={selectedFormat?.label ?? ''}
          items={formatItems} selectedItem={selectedFormat} itemToString={(i) => i?.label ?? ''}
          onChange={({ selectedItem }) => setFormatId(selectedItem && selectedItem.id !== ID_NONE ? selectedItem.id : '')} />
      </div>
      {customNames.map((name) => (
        <div className="newdoc__field" key={name}>
          <TextInput id={`new-doc-custom-${name}`} labelText={titleCase(name)} placeholder={t('newDoc.customHint', { name })}
            value={customVars[name] ?? ''} onChange={(e) => setCustomVars((p) => ({ ...p, [name]: e.target.value }))} />
        </div>
      ))}
    </Modal>
  )
}

NewDocumentInput no longer has mode, file, or folderId. Task A5 updates the caller.

  • [ ] Step 2: Verifynpx tsc --noEmit will FAIL until A5 updates DocumentsPage (the caller uses the removed fields). That's expected; do A5 in the same commit or right after. Proceed to A5.

Task A5: Split the toolbar; wire both modals

Files:
- Modify: web/src/features/documents/DocumentsPage.tsx (toolbar ~225; submitNewDoc 90-117; imports)
- Reference: current folder path — use the folder strip / useFolders current folder name; if a full path isn't readily available, pass the current folder's name/path from the folder data (see step).

  • [ ] Step 1: Imports + state. Add the upload modal import and an uploadOpen state; keep newDocOpen for write:
import { NewDocumentModal, type NewDocumentInput } from './NewDocumentModal'
import { UploadDocumentModal, type UploadDocumentInput } from './UploadDocumentModal'

Add near const [newDocOpen, setNewDocOpen] = useState(false):

  const [uploadOpen, setUploadOpen] = useState(false)
  const [uploadError, setUploadError] = useState<string | null>(null)
  • [ ] Step 2: Current folder path for the box. Derive a display path for the current folder from the loaded folders/breadcrumb. Minimal approach — use the current folder's name when known, else '/':
  // A display path for the current folder (used as the "Current folder" option label).
  const currentFolderPath = currentFolderId
    ? (folders.find((f) => f.id === currentFolderId)?.name ?? '')
    : '/'

(folders lists the CHILDREN of the current folder, so it won't contain the current folder itself; a precise materialized path would come from a breadcrumb hook. Using '/' at root and letting the box fall back to the id is acceptable for v1 — the box still files correctly. If a precise path is wanted, read it from the folder-detail/breadcrumb hook used by FolderStrip.)

  • [ ] Step 3: Rewrite submitNewDoc (write path only — no file/folder now) and add submitUpload:
  // Write path: create a stub in the current folder and open the editor (unchanged behavior).
  const submitNewDoc = async ({ title, classification, docType, formatId, customVars }: NewDocumentInput) => {
    setSavingDoc(true)
    setNewDocError(null)
    try {
      const id = await createDocument.mutateAsync({ title, folderId: currentFolderId, classification, docType, formatId, customVars })
      if (!id) return
      setNewDocOpen(false)
      navigate(`/documents/d/${id}/edit?folder=${folderParam ?? 'root'}`)
    } catch (e) {
      setNewDocError(errMsg(e))
    } finally {
      setSavingDoc(false)
    }
  }

  // Upload path: create the doc in the SELECTED folder, upload the file as version 1, open detail.
  const submitUpload = async ({ title, classification, docType, formatId, customVars, file, folderId }: UploadDocumentInput) => {
    setSavingDoc(true)
    setUploadError(null)
    try {
      const id = await createDocument.mutateAsync({ title, folderId, classification, docType, formatId, customVars })
      if (!id) return
      setUploadOpen(false)
      try { await uploadVersion(id, file) } catch { /* stub created; retry from detail */ }
      navigate(`/documents/d/${id}?folder=${folderId ?? 'root'}`)
    } catch (e) {
      setUploadError(errMsg(e))
    } finally {
      setSavingDoc(false)
    }
  }
  • [ ] Step 4: Toolbar — replace the single button (line ~225) with two:
            <Button kind="tertiary" size="sm" renderIcon={Edit} onClick={() => setNewDocOpen(true)}>
              {t('newDoc.writeTitle')}
            </Button>
            <Button kind="primary" size="sm" renderIcon={DocumentAdd} onClick={() => setUploadOpen(true)}>
              {t('newDoc.uploadTitle')}
            </Button>

Add Edit to the @carbon/icons-react import (line 9): import { DocumentAdd, FolderAdd, Edit } from '@carbon/icons-react'.

  • [ ] Step 5: Render the upload modal — next to the existing <NewDocumentModal .../> (line 315):
      <UploadDocumentModal
        open={uploadOpen}
        busy={savingDoc}
        error={uploadError}
        currentFolderId={currentFolderId}
        currentFolderPath={currentFolderPath}
        onSubmit={submitUpload}
        onClose={() => { setUploadOpen(false); setUploadError(null) }}
      />
  • [ ] Step 6: Verify + commit
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
cd /home/efran/remote-development/obscura
git add -A
git commit -m "feat(web): split New Document into Upload + Write buttons; wire redesigned upload modal"

Expected: both clean.


Task A6: Deploy Phase A + e2e

Files: none (verification only)

  • [ ] Step 1: Deploy the web app + assert modules:
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build web obscura

Run the post-deploy assertion snippet (expect the 5 modules).

  • [ ] Step 2: Manual UI e2e at http://localhost:8091 (log in as admin):
  • Toolbar shows Write document + Upload document.
  • Upload document → a drop zone appears first; drop a file → form expands, title autofilled from the filename (extension stripped, editable); the folder box shows AI suggestions (semantic on) + Current folder + Browse, defaulting to Current folder; Create files the upload into the selected folder (verify the doc lands there).
  • Write document → the slim modal (title/classification/id) → Create opens the editor (unchanged).
  • Confirm the old blue chip is gone.
  • [ ] Step 3: Clean up any test docs created; confirm the demo is intact.

Phase A shippable here. Stop and let the user review before Phase B if desired.


PHASE B — Write reframe (direct editor + Save modal) + backend

Task B1: Backend — assign Document ID to an existing doc

Files:
- Modify: go/internal/dms/app/service.go (Repository port; new AssignReference near CreateDocument)
- Modify: go/internal/dms/adapters/pg.go (new SetDocumentReference)
- Modify: go/internal/httpapi/handlers_dms_*.go + server.go (route)
- Modify: api/openapi.yaml (+ npm run gen:api)

  • [ ] Step 1: Repository method — add to the Repository interface (near RenameDocument) in service.go:
    // SetDocumentReference sets documents.reference for a document that has none yet.
    // kernel.ErrNotFound if absent.
    SetDocumentReference(ctx context.Context, docID, reference string) error
  • [ ] Step 2: Adapter — add to pg.go (mirror RenameDocument's not-found form):
// SetDocumentReference sets documents.reference. kernel.ErrNotFound if the document is absent.
func (s *Store) SetDocumentReference(ctx context.Context, docID, reference string) error {
    tag, err := s.db.Exec(ctx).Exec(ctx, `UPDATE documents SET reference = $2 WHERE id = $1`, docID, reference)
    if err != nil {
        return fmt.Errorf("dms set document reference: %w", err)
    }
    if tag.RowsAffected() == 0 {
        return &kernel.Error{Kind: kernel.ErrNotFound, Code: "dms.document.not_found", Message: "document not found"}
    }
    return nil
}
  • [ ] Step 3: Service method — add to service.go near CreateDocument. It reuses allocateReference (which needs the doc's docType + classification + the passed customs) and refuses if a reference already exists (immutability):
// AssignReference assigns a Document ID (reference) to a document that has NONE yet, using the
// chosen format. Rejects if a reference is already present (references are immutable once set) —
// this is the deferred-assignment path for in-app authored documents whose ID is chosen at first
// save rather than at create. Returns the rendered reference.
func (s *Service) AssignReference(ctx context.Context, docID string, formatID *string, customs map[string]string) (string, error) {
    if docID == "" {
        return "", &kernel.Error{Kind: kernel.ErrValidation, Code: "dms.document.id_required", Message: "document id is required"}
    }
    var ref string
    err := s.uow.Do(ctx, func(ctx context.Context) error {
        doc, err := s.repo.GetDocument(ctx, docID)
        if err != nil {
            return err
        }
        if strings.TrimSpace(doc.Reference) != "" {
            return &kernel.Error{Kind: kernel.ErrConflict, Code: "dms.docid.already_assigned", Message: "this document already has an ID"}
        }
        r, err := s.allocateReference(ctx, formatID, doc.DocType, doc.Classification, customs)
        if err != nil {
            return err
        }
        if r == "" {
            return nil // no format / numbering disabled → nothing to assign
        }
        if err := s.repo.SetDocumentReference(ctx, docID, r); err != nil {
            return err
        }
        ref = r
        return nil
    })
    return ref, err
}

(strings is already imported in service.go.)

  • [ ] Step 4: HTTP handler — add to handlers_dms_nav.go (or a suitable dms handler file), mirroring RenameFolder's decode/response idiom:
// AssignDocumentID assigns a Document ID to a document that has none (deferred assignment for
// authored docs). Body: {format_id?: string, custom_vars?: {name: value}}. Returns {reference}.
func (s *Server) AssignDocumentID(w http.ResponseWriter, r *http.Request) {
    var body struct {
        FormatID   *string           `json:"format_id"`
        CustomVars map[string]string `json:"custom_vars"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
        return
    }
    ref, err := s.dms.AssignReference(r.Context(), chi.URLParam(r, "docID"), body.FormatID, body.CustomVars)
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, map[string]any{"reference": ref})
}
  • [ ] Step 5: Route — in server.go, in the per-document group (near Put("/doc-type", s.SetDocumentType)), add:
                r.With(s.requireAccess(dmsdomain.AccessReadWrite)).Post("/assign-id", s.AssignDocumentID)
  • [ ] Step 6: OpenAPI — add POST /api/v1/documents/{docID}/assign-id to api/openapi.yaml (request {format_id?: string, custom_vars?: object}, response {reference: string}), matching the file's style. Then:
cd /home/efran/remote-development/obscura/web && npm run gen:api
  • [ ] Step 7: Verify + commit
cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
cd /home/efran/remote-development/obscura && git add -A
git commit -m "feat(dms): assign Document ID to an existing doc (deferred, immutable)"

Expected: build/vet clean.


Task B2: Backend — editable_ids on the documents list

Files:
- Modify: go/internal/dms/app/service.go (Repository + a Service wrapper)
- Modify: go/internal/dms/adapters/content_pg.go (SQL)
- Modify: go/internal/httpapi/handlers_dms_nav.go (ListDocuments at :132)

  • [ ] Step 1: Repository method — add to the Repository interface:
    // DocumentIDsWithHTMLVersion returns, of the given doc ids, those that have at least one
    // text/html version (i.e. an editable authored source). Used to flag the documents list.
    DocumentIDsWithHTMLVersion(ctx context.Context, ids []string) ([]string, error)
  • [ ] Step 2: Adapter SQL — add to content_pg.go:
// DocumentIDsWithHTMLVersion returns the subset of ids that have >=1 text/html version.
func (s *Store) DocumentIDsWithHTMLVersion(ctx context.Context, ids []string) ([]string, error) {
    if len(ids) == 0 {
        return nil, nil
    }
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT DISTINCT document_id::text FROM document_versions WHERE document_id::text = ANY($1::text[]) AND mime LIKE 'text/html%'`, ids)
    if err != nil {
        return nil, fmt.Errorf("editable doc ids: %w", err)
    }
    defer rows.Close()
    var out []string
    for rows.Next() {
        var id string
        if err := rows.Scan(&id); err != nil {
            return nil, err
        }
        out = append(out, id)
    }
    return out, rows.Err()
}
  • [ ] Step 3: Service wrapper — add to service.go:
// EditableDocumentIDs returns which of the given documents have an editable (text/html) source
// version, for the documents-list "editable" badge.
func (s *Service) EditableDocumentIDs(ctx context.Context, ids []string) ([]string, error) {
    return s.repo.DocumentIDsWithHTMLVersion(ctx, ids)
}
  • [ ] Step 4: Enrich the list handler — in handlers_dms_nav.go ListDocuments (:132-140), before writing the response, gather ids + fetch editable ids:
    ids := make([]string, 0, len(docs))
    for _, d := range docs {
        ids = append(ids, d.ID)
    }
    editable, err := s.dms.EditableDocumentIDs(r.Context(), ids)
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, map[string]any{"documents": nonNilDocs(docs), "editable_ids": editable})

(Replace the existing single writeJSON(...) at :140. Leave the other list handlers — trash/search — unchanged; the badge is for the main folder listing.)

  • [ ] Step 5: OpenAPI — update the GET /api/v1/documents (and /folders/{id} listing if that's the operation) response schema to include editable_ids: string[]. Then cd web && npm run gen:api.

  • [ ] Step 6: Verify + commit

cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
cd /home/efran/remote-development/obscura && git add -A
git commit -m "feat(dms): editable_ids on the documents list (text/html source flag)"

Task B3: Frontend — editable flag + table badge + version labels

Files:
- Modify: web/src/api/types.ts (DocFlag + DocumentRow)
- Modify: web/src/api/documents.ts (useDocuments mapping — thread editable_ids into toRow)
- Modify: web/src/components/DocFlags.tsx (add the editable icon)
- Modify: web/src/api/document-detail.ts + the versions rendering (labels)
- Modify: web/src/i18n/locales/en.ts + id.ts (flag.editable, version.editableDraft, version.pdf)

  • [ ] Step 1: Typestypes.ts:
export type DocFlag = 'shared' | 'locked' | 'hold' | 'editable'
  • [ ] Step 2: DocFlags iconDocFlags.tsx, add to FLAG_META (import Edit):
import { Share, Locked, Scales, Edit } from '@carbon/icons-react'
  editable: { Icon: Edit, key: 'flag.editable' },
  • [ ] Step 3: Thread editable_idsdocuments.ts useDocuments: the list query response now has editable_ids: string[]. In the mapping, pass the set into toRow and push the flag. Find where toRow is called over the documents and change it to:
    const editable = new Set<string>(res.editable_ids ?? [])
    return (res.documents ?? []).map((d: ApiDocument) => toRow(d, users, editable))

And update toRow:

function toRow(d: ApiDocument, users: Map<string, Person>, editable?: Set<string>): DocumentRow {
  const flags: DocFlag[] = []
  if (d.LegalHold) flags.push('hold')
  if (editable?.has(d.ID)) flags.push('editable')
  return {
    // ...unchanged fields...
    flags,
  }
}

(Read the exact useDocuments query fn to see how res is shaped and where toRow is mapped; match it. If other callers of toRow exist, the new param is optional so they still compile.)

  • [ ] Step 4: Version labels — in the versions rendering (DocumentDetailView.tsx versions section ~315-386 and/or DocumentDetailPanel.tsx ~243-267), replace the raw MIME display with a friendly label:
const mimeLabel = (mime: string) =>
  mime.startsWith('text/html') ? t('version.editableDraft') : mime.startsWith('application/pdf') ? t('version.pdf') : mime

Use mimeLabel(v.mime) where the MIME string is currently shown.

  • [ ] Step 5: i18n — add to en.ts: flag: { ..., editable: 'Editable' } (extend the existing flag block) and a version block { editableDraft: 'Editable draft', pdf: 'PDF' }; mirror in id.ts: editable: 'Dapat diedit', { editableDraft: 'Draf editabel', pdf: 'PDF' }.

  • [ ] Step 6: Verify + commit

cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
cd /home/efran/remote-development/obscura && git add -A
git commit -m "feat(web): editable table badge + friendly version labels"

Task B4: SaveDocumentModal

Files:
- Create: web/src/features/documents/SaveDocumentModal.tsx
- Modify: web/src/api/files.ts (add assignDocumentId)

  • [ ] Step 1: API call — add to files.ts:
// Assign a Document ID to an authored doc that has none yet (deferred assignment at first save).
export async function assignDocumentId(docId: string, formatId: string, customVars: Record<string, string>): Promise<string> {
  const { data, error } = await api.POST('/api/v1/documents/{docID}/assign-id', {
    params: { path: { docID: docId } },
    body: { format_id: formatId || null, custom_vars: customVars },
  })
  if (error) throw new Error((error as { detail?: string }).detail ?? 'assign id failed')
  return data?.reference ?? ''
}

(Match the exact api.POST path-param call style used by the other calls in files.ts; regen must have added this path.)

  • [ ] Step 2: The modal. Collects title/classification/Document-ID (only if the doc has none)/folder, and exposes two actions. It reuses FolderSuggestBox.
// Save modal for authored documents: collect title/classification/(Document ID)/folder and either
// Save (finalize to PDF, stays editable) or Save as draft (HTML only). Reuses FolderSuggestBox.
import { useEffect, useState } from 'react'
import { Dropdown, Modal, TextInput } from '@carbon/react'
import { useTranslation } from 'react-i18next'
import { type Classification } from '@/lib/classification'
import { useActiveClassifications } from '@/api/classifications'
import { useIdFormats } from '@/api/docid'
import { FolderSuggestBox, type FolderChoice } from './FolderSuggestBox'

const ID_NONE = '__none__'

export interface SaveDocumentResult {
  action: 'save' | 'draft'
  title: string
  classification: Classification
  formatId: string
  customVars: Record<string, string>
  folderId: string | null
}

interface Props {
  open: boolean
  busy?: boolean
  editorText: string
  initialTitle: string
  needsId: boolean // true when the doc has no reference yet (show the ID picker)
  currentFolderId: string | null
  currentFolderPath: string
  onSubmit: (r: SaveDocumentResult) => void
  onClose: () => void
}

export function SaveDocumentModal({ open, busy, editorText, initialTitle, needsId, currentFolderId, currentFolderPath, onSubmit, onClose }: Props) {
  const { t } = useTranslation()
  const { data: formats = [] } = useIdFormats()
  const levels = useActiveClassifications()
  const CLASS_ITEMS = levels.map((c) => ({ id: c.code, label: c.label }))

  const [title, setTitle] = useState(initialTitle)
  const [classification, setClassification] = useState<Classification>('none')
  const [formatId, setFormatId] = useState('')
  const [folder, setFolder] = useState<FolderChoice>({ folderId: currentFolderId, path: currentFolderPath })

  useEffect(() => {
    if (open) {
      setTitle(initialTitle)
      setClassification('none')
      const def = formats.find((f) => f.isDefault)
      setFormatId(def ? def.id : '')
      setFolder({ folderId: currentFolderId, path: currentFolderPath })
    }
  }, [open]) // eslint-disable-line react-hooks/exhaustive-deps

  const formatItems = [{ id: ID_NONE, label: t('newDoc.noId') }, ...formats.map((f) => ({ id: f.id, label: `${f.name} · ${f.pattern}` }))]
  const selectedFormat = formatItems.find((i) => i.id === (formatId || ID_NONE)) ?? formatItems[0]
  const trimmed = title.trim()

  const emit = (action: 'save' | 'draft') => {
    if (!trimmed || busy) return
    onSubmit({ action, title: trimmed, classification, formatId, customVars: {}, folderId: folder.folderId })
  }

  return (
    <Modal
      open={open}
      size="sm"
      modalHeading={t('save.title')}
      primaryButtonText={t('save.save')}
      secondaryButtonText={t('save.draft')}
      primaryButtonDisabled={!trimmed || busy}
      onRequestClose={onClose}
      onRequestSubmit={() => emit('save')}
      onSecondarySubmit={() => emit('draft')}
    >
      <TextInput id="save-doc-title" labelText={t('newDoc.titleLabel')} value={title} onChange={(e) => setTitle(e.target.value)} data-modal-primary-focus />
      <div className="newdoc__field">
        <Dropdown id="save-doc-classification" titleText={t('newDoc.classification')}
          label={CLASS_ITEMS.find((i) => i.id === classification)?.label ?? classification}
          items={CLASS_ITEMS} selectedItem={CLASS_ITEMS.find((i) => i.id === classification) ?? null}
          itemToString={(i) => i?.label ?? ''} onChange={({ selectedItem }) => selectedItem && setClassification(selectedItem.id)} />
      </div>
      {needsId && (
        <div className="newdoc__field">
          <Dropdown id="save-doc-format" titleText={t('newDoc.format')} helperText={t('newDoc.formatHint')} label={selectedFormat?.label ?? ''}
            items={formatItems} selectedItem={selectedFormat} itemToString={(i) => i?.label ?? ''}
            onChange={({ selectedItem }) => setFormatId(selectedItem && selectedItem.id !== ID_NONE ? selectedItem.id : '')} />
        </div>
      )}
      <FolderSuggestBox
        query={{ title, text: editorText }}
        enabled={open}
        currentFolderId={currentFolderId}
        currentFolderPath={currentFolderPath}
        value={folder}
        onChange={setFolder}
      />
    </Modal>
  )
}

Carbon's Modal supports a secondary submit via onSecondarySubmit; the secondary button here is "Save as draft". If the installed Carbon version doesn't fire onSecondarySubmit, render explicit footer buttons instead — verify against the Carbon version in web/package.json.

  • [ ] Step 3: i18n — add a save block to en.ts ({ title: 'Save document', save: 'Save', draft: 'Save as draft' }) and id.ts ({ title: 'Simpan dokumen', save: 'Simpan', draft: 'Simpan draf' }).

  • [ ] Step 4: Verifynpx tsc --noEmit (will pass once assign-id path is in schema from B1). Commit with B5 (they wire together), or commit now:

cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
cd /home/efran/remote-development/obscura && git add -A && git commit -m "feat(web): SaveDocumentModal (title/classification/id/folder, Save vs draft)"

Task B5: Editor — single Save → SaveDocumentModal; Write → direct editor

Files:
- Modify: web/src/features/documents/TextEditorView.tsx (buttons 382-399; save/finalize 303-335)
- Modify: web/src/features/documents/DocumentsPage.tsx (Write button → direct unfiled draft; drop the write modal)

  • [ ] Step 1: DocumentsPage — Write goes direct. Replace the write flow: the Write document toolbar button now creates an unfiled, ID-less draft and navigates straight to the editor. Remove the NewDocumentModal usage.

Change the Write button (from A5 step 4) to call a handler:

            <Button kind="tertiary" size="sm" renderIcon={Edit} onClick={() => void startWriting()} disabled={savingDoc}>
              {t('newDoc.writeTitle')}
            </Button>

Add the handler (creates unfiled, no ID, placeholder title):

  // Write: create an unfiled, ID-less draft and jump into the editor. Metadata + filing happen
  // at first save (SaveDocumentModal).
  const startWriting = async () => {
    setSavingDoc(true)
    setActionError(null)
    try {
      const id = await createDocument.mutateAsync({ title: t('newDoc.untitled'), folderId: null, classification: 'none', docType: '', formatId: '', customVars: {} })
      if (id) navigate(`/documents/d/${id}/edit?folder=root`)
    } catch (e) {
      setActionError(errMsg(e))
    } finally {
      setSavingDoc(false)
    }
  }

Remove the <NewDocumentModal .../> render block and its newDocOpen/newDocError/submitNewDoc state + the NewDocumentModal import (now unused). Add i18n newDoc.untitled = 'Untitled document' / 'Dokumen tanpa judul'.

createDocument requires a non-empty title (backend validates), hence the placeholder; formatId: '' = no ID (deferred).

  • [ ] Step 2: Editor buttons. In TextEditorView.tsx, replace the two buttons (Finalize + Save, lines 382-399) with a single primary Save that opens the save modal:
          <Button
            kind="primary"
            size="sm"
            renderIcon={Save}
            disabled={saving || !editor || readOnly}
            onClick={() => setSaveOpen(true)}
          >
            {saving ? t('editor.saving') : t('editor.save')}
          </Button>

Remove the separate Finalize button + the FinalizeModal render (the finalize logic is reused via the save modal). Keep the letterhead dropdown/preview as-is.

  • [ ] Step 3: Wire the save modal + actions. Add state const [saveOpen, setSaveOpen] = useState(false) and render:
      <SaveDocumentModal
        open={saveOpen}
        busy={saving}
        editorText={editor?.getText() ?? ''}
        initialTitle={doc.title}
        needsId={!doc.reference}
        currentFolderId={folderId}
        currentFolderPath={''}
        onSubmit={(r) => void onSaveSubmit(r)}
        onClose={() => setSaveOpen(false)}
      />

Add the submit handler that: (a) sets title/classification if changed; (b) assigns the ID on first save; (c) Save → persistHtml + finalize(folderId); Save-as-draft → persistHtml + move:

  const onSaveSubmit = async (r: SaveDocumentResult) => {
    if (!editor || !doc) return
    setSaving(true)
    setError(null)
    try {
      if (r.title && r.title !== doc.title) await renameDocument(docId, r.title)
      if (r.classification !== doc.classification) await setDocumentClassification(docId, r.classification)
      if (!doc.reference && r.formatId) await assignDocumentId(docId, r.formatId, r.customVars)
      await persistHtml()
      if (r.action === 'save') {
        // Finalize to PDF, filing into the chosen folder; the doc stays editable (HTML retained).
        await finalizeDocument(docId, { mode: 'replace', folderId: r.folderId, letterheadId })
      } else {
        // Draft: HTML only, file into the chosen folder.
        await moveDocument(docId, r.folderId)
      }
      setSaveOpen(false)
      invalidate(docId)
      releaseLock()
      navigate(`/documents/d/${docId}${r.folderId ? `?folder=${r.folderId}` : ''}`)
    } catch {
      setError(t('editor.saveFailed'))
      setSaving(false)
    }
  }

Import the helpers: assignDocumentId, finalizeDocument (already imported), and add renameDocument, setDocumentClassification, moveDocument API calls (check web/src/api/* for their exact names — documents.ts has useRenameDocument/useMoveDocument/setDocumentStatus; use the imperative files.ts/documents.ts equivalents, or call the mutations. If only hook-form exists, add thin imperative wrappers in files.ts mirroring finalizeDocument). doc.reference — ensure the editor's doc object (from useDocumentDetail) exposes reference; if not, add it to that hook's mapping (the GetDocument response carries Reference).

finalizeDocument's opts type is FinalizeInput ({ mode, title?, formatId?, folderId?, letterheadId? }); pass mode: 'replace'. Confirm the exact field names in files.ts:100-119.

  • [ ] Step 4: Verify + commit
cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
cd /home/efran/remote-development/obscura && git add -A
git commit -m "feat(web): write→direct editor; single Save→SaveDocumentModal (PDF vs draft) + deferred ID"

Expected: clean (delete the now-unused NewDocumentModal.tsx, FinalizeModal.tsx if fully unused — grep for other importers first; if still used elsewhere, keep).


Task B6: Deploy Phase B + e2e

Files: none (verification only)

  • [ ] Step 1: Deploy full stack + assert modules:
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build

Post-deploy assertion snippet (expect 5 modules); demo intact.

  • [ ] Step 2: e2e (curl + UI):
  • Write document → lands directly in the editor; the created doc is unfiled (folder_id null) and has no reference (SELECT reference, folder_id FROM documents WHERE id=… → empty/null).
  • Author content → Save → the save modal shows title/classification/ID picker + folder box (AI suggestions from the real content + current folder + browse). Choose a folder + format → Save: assert (a) reference is now assigned, (b) folder_id = chosen, (c) a application/pdf version exists, (d) the doc still has a text/html version (editable), (e) the documents table shows the Editable badge, (f) the versions tab labels rows "Editable draft" / "PDF".
  • Re-open the editor, Save as draft into a folder → assert only a new text/html version (no new PDF) and the doc is not publishable (publish → 409 not_finalized).
  • Second Save on the now-ID'd doc → the ID picker is hidden and the reference is unchanged (immutability): POST /assign-id again returns 409 already_assigned.
  • [ ] Step 3: Clean up all test docs; confirm the demo is intact.

  • [ ] Step 4: Final review — dispatch a code reviewer over the Phase B diff (fail-closed folder box, deferred-ID immutability, no go test, builds clean) and report.


Self-review notes (author)

  • Spec coverage: two entry points (A5), drag-drop-first + autofill (A3), folder box multi-suggestion + current-folder-always + degrade (A2), chip removed (A3/A4), write→direct editor (B5), Save modal Save/draft (B4/B5), deferred ID (B1/B5), table badge (B2/B3), version labels (B3), reuse finalize/versions/suggest — all mapped.
  • Known verification points to confirm during execution (flagged inline, not placeholders): the exact useDocuments response mapping shape for editable_ids (B3 Step 3); whether the editor doc exposes reference (B5 Step 3); Carbon onSecondarySubmit support for the draft button (B4 Step 2); the imperative names for rename/classification/move (B5 Step 3); the precise current-folder materialized path (A5 Step 2 — v1 falls back to id/'/'). Each has a stated fallback.
  • Phase A ships independently (no backend, no write-path regression — Write keeps today's behavior via the stripped modal until B5).