Fosi Fossil Service
Small teams. Open source. Built on Fossil.

IaC / SwiftFlow

High-performance WMS with wave picking, smart routing, and real-time inventory visibility.

Public | Owner: IaC | Default branch: trunk | Last update: 2026-10-23 14:37

Artifact 88976fa682af99c645fb653e0b4a5dc5b13bc3324f8935024a76aa7a23f3be95:


import { useEffect, useState } from 'react'
import { api, can, type Warehouse } from '../api'
import { useSession } from '../session'
import { Banner, Empty, Field, Page } from '../ui'

type LocationRow = {
  code: string
  barcode: string
  enabled: boolean
  created_at: string
}

type PlanResult = {
  new_count: number
  existing_count: number
  locations: LocationRow[]
  existing: string[]
}

type Range = { from: string; to: string; width: string }

const emptyRange = (): Range => ({ from: '1', to: '1', width: '2' })

export function Locations() {
  const { user } = useSession()
  const manage = can(user, 'location.manage')
  const [warehouses, setWarehouses] = useState<Warehouse[]>([])
  const [warehouseId, setWarehouseId] = useState(0)
  const [rows, setRows] = useState<LocationRow[]>([])
  const [areas, setAreas] = useState('A')
  const [aisle, setAisle] = useState<Range>({ from: '1', to: '2', width: '2' })
  const [level, setLevel] = useState<Range>(emptyRange())
  const [bin, setBin] = useState<Range>({ from: '1', to: '2', width: '2' })
  const [preview, setPreview] = useState<PlanResult | null>(null)
  const [error, setError] = useState('')
  const [ok, setOk] = useState('')

  useEffect(() => {
    api<{ warehouses: Warehouse[] }>('/api/warehouses')
      .then((data) => {
        setWarehouses(data.warehouses)
        const current = data.warehouses.find((row) => row.id === user?.current_warehouse_id)
        const first = current ?? data.warehouses.find((row) => row.operational) ?? data.warehouses[0]
        if (first) setWarehouseId(first.id)
      })
      .catch((err: unknown) => setError(err instanceof Error ? err.message : '加载失败'))
  }, [user?.current_warehouse_id])

  useEffect(() => {
    if (!warehouseId) return
    api<{ locations: LocationRow[] }>(`/api/locations?warehouse_id=${warehouseId}`)
      .then((data) => setRows(data.locations))
      .catch((err: unknown) => setError(err instanceof Error ? err.message : '库位加载失败'))
  }, [warehouseId])

  function body() {
    const range = (value: Range) => ({
      from: Number(value.from),
      to: Number(value.to),
      width: Number(value.width || 2),
    })
    return {
      warehouse_id: warehouseId,
      areas: areas.split(/[,,\s]+/).map((item) => item.trim()).filter(Boolean),
      aisle: range(aisle),
      level: range(level),
      bin: range(bin),
    }
  }

  function reload() {
    setWarehouseId((id) => id)
    api<{ locations: LocationRow[] }>(`/api/locations?warehouse_id=${warehouseId}`)
      .then((data) => setRows(data.locations))
      .catch((err: unknown) => setError(err instanceof Error ? err.message : '库位加载失败'))
  }

  async function plan(commit: boolean) {
    setError('')
    setOk('')
    const payload = body()
    if (payload.areas.length === 0) {
      setError('至少填写一个库区')
      return
    }
    if (commit && !window.confirm(`确定生成 ${preview?.new_count ?? 0} 个新库位?`)) return
    try {
      const path = commit ? '/api/locations/plan' : '/api/locations/plan/preview'
      const result = await api<PlanResult>(path, {
        method: 'POST',
        body: JSON.stringify(payload),
      })
      setPreview(result)
      if (commit) {
        setOk(`已生成 ${result.new_count} 个库位,跳过已有 ${result.existing_count} 个`)
        setPreview(null)
        reload()
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : '规划失败')
    }
  }

  async function disable(code: string) {
    if (!window.confirm(`停用库位 ${code}?`)) return
    setError('')
    try {
      await api('/api/locations/disable', {
        method: 'POST',
        body: JSON.stringify({ warehouse_id: warehouseId, code }),
      })
      reload()
    } catch (err) {
      setError(err instanceof Error ? err.message : '停用失败')
    }
  }

  const selected = warehouses.find((row) => row.id === warehouseId)

  return (
    <Page title="库位">
      <Banner kind="error" text={error} />
      <Banner kind="ok" text={ok} />
      <Field label="仓库">
        <select
          value={warehouseId || ''}
          onChange={(event) => {
            setPreview(null)
            setWarehouseId(Number(event.target.value))
          }}
        >
          {warehouses.map((row) => (
            <option key={row.id} value={row.id}>
              {row.name}({row.code}){row.operational ? '' : ' · 仅显示'}
            </option>
          ))}
        </select>
      </Field>
      {manage && selected?.operational && (
        <div className="panel">
          <h2>按区间规划</h2>
          <p className="muted">编码为 库区-货架-层-位,例如 A-01-02-03。先预览,确认数量后再生成。</p>
          <Field label="库区,多个用逗号分隔">
            <input value={areas} onChange={(event) => { setAreas(event.target.value); setPreview(null) }} />
          </Field>
          <RangeEditor label="货架" value={aisle} onChange={(value) => { setAisle(value); setPreview(null) }} />
          <RangeEditor label="层" value={level} onChange={(value) => { setLevel(value); setPreview(null) }} />
          <RangeEditor label="位" value={bin} onChange={(value) => { setBin(value); setPreview(null) }} />
          <div className="actions">
            <button type="button" onClick={() => void plan(false)}>预览</button>
            <button type="button" disabled={!preview || preview.new_count === 0} onClick={() => void plan(true)}>
              生成库位
            </button>
          </div>
          {preview && (
            <p>
              将新建 {preview.new_count} 个,已存在 {preview.existing_count} 个。
              {preview.locations.slice(0, 30).map((row) => row.code).join('、')}
              {preview.locations.length > 30 ? '…' : ''}
            </p>
          )}
        </div>
      )}
      {rows.length === 0 ? (
        <Empty text="这个仓库还没有库位" />
      ) : (
        <div className="table-wrap">
          <table>
            <thead>
              <tr>
                <th>库位</th>
                <th>条码</th>
                <th>状态</th>
                <th>建立时间</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row) => (
                <tr key={row.code}>
                  <td className="mono">{row.code}</td>
                  <td className="mono">{row.barcode}</td>
                  <td>
                    <span className={row.enabled ? 'tag ok' : 'tag off'}>
                      {row.enabled ? '启用' : '停用'}
                    </span>
                  </td>
                  <td>{row.created_at}</td>
                  <td className="row-actions">
                    {manage && row.enabled && (
                      <button type="button" className="ghost" onClick={() => void disable(row.code)}>
                        停用
                      </button>
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </Page>
  )
}

function RangeEditor({
  label,
  value,
  onChange,
}: {
  label: string
  value: Range
  onChange: (value: Range) => void
}) {
  return (
    <div className="range-row">
      <span>{label}</span>
      <input value={value.from} onChange={(event) => onChange({ ...value, from: event.target.value })} />
      <span>到</span>
      <input value={value.to} onChange={(event) => onChange({ ...value, to: event.target.value })} />
      <span>位数</span>
      <input value={value.width} onChange={(event) => onChange({ ...value, width: event.target.value })} />
    </div>
  )
}

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/IaC/SwiftFlow

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.