Some checks failed
CI / Backend (.NET 8) (push) Has been cancelled
CI / Web (React + Vite) (push) Has been cancelled
CI / POS (WPF, Windows) (push) Has been cancelled
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
S10-4: script-патчер обработал 29 файлов (pages + components).
Подход: посимвольный скан каждой строки с className. Если есть
text-slate-{500..900} / bg-white / bg-slate-{50,100} / border-slate-{100,200,300}
БЕЗ dark:* для того же префикса (text/bg/border/divide/hover-bg) — добавляем
соответствующий dark-companion рядом. Идемпотентен.
Стратегия маппинга:
- text-slate-500 → +dark:text-slate-400
- text-slate-700 → +dark:text-slate-200
- text-slate-900 → +dark:text-slate-100
- bg-white → +dark:bg-slate-900
- bg-slate-50 → +dark:bg-slate-800/60
- border-slate-200 → +dark:border-slate-800
- hover:bg-slate-50 → +dark:hover:bg-slate-800/50
- … и аналогичные.
Skip если на той же строке уже есть dark:<prefix>-* (например
dark:bg-blue-500) — не трогаем чужие осознанные dark-выборы.
stage-ui-s10-dark-audit.spec.ts снимает 20 скриншотов (10 страниц
× light/dark) в reports/dark-mode/. Визуально проверены Dashboard,
ABC-report, Products — контраст ок, brand-зелёный сохранён,
sidebar/таблицы/виджеты читаемы.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
import { useState, useEffect, type FormEvent } from 'react'
|
||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
||
import { api } from '@/lib/api'
|
||
import { Button } from '@/components/Button'
|
||
import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox } from '@/components/Field'
|
||
import { DateField } from '@/components/DateField'
|
||
import { ProductPicker } from '@/components/ProductPicker'
|
||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||
import { FormSkeleton } from '@/components/Skeleton'
|
||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||
import { useConfirm } from '@/lib/useConfirm'
|
||
import { useShortcuts } from '@/lib/useShortcuts'
|
||
import { useStores, useCurrencies } from '@/lib/useLookups'
|
||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||
import {
|
||
DemandStatus, DemandPayment, demandPaymentLabel,
|
||
type DemandDto, type Product,
|
||
} from '@/lib/types'
|
||
|
||
interface LineRow {
|
||
productId: string
|
||
productName: string
|
||
productArticle: string | null
|
||
unitSymbol: string | null
|
||
quantity: number
|
||
unitPrice: number
|
||
discount: number
|
||
vatPercent: number
|
||
stockAtStore: number | null
|
||
}
|
||
|
||
interface Form {
|
||
date: string
|
||
customerId: string
|
||
storeId: string
|
||
currencyId: string
|
||
payment: DemandPayment
|
||
paidAmount: number
|
||
notes: string
|
||
lines: LineRow[]
|
||
}
|
||
|
||
const todayIso = () => {
|
||
const d = new Date()
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
const emptyForm: Form = {
|
||
date: todayIso(), customerId: '', storeId: '', currencyId: '',
|
||
payment: DemandPayment.BankTransfer, paidAmount: 0,
|
||
notes: '', lines: [],
|
||
}
|
||
|
||
export function DemandEditPage() {
|
||
const { id } = useParams<{ id: string }>()
|
||
const isNew = !id || id === 'new'
|
||
const navigate = useNavigate()
|
||
const qc = useQueryClient()
|
||
|
||
const stores = useStores()
|
||
const currencies = useCurrencies()
|
||
const org = useOrgSettings()
|
||
const { confirm, dialogProps } = useConfirm()
|
||
|
||
const [form, setForm] = useState<Form>(emptyForm)
|
||
const [pickerOpen, setPickerOpen] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const existing = useQuery({
|
||
queryKey: ['/api/sales/demands', id],
|
||
queryFn: async () => (await api.get<DemandDto>(`/api/sales/demands/${id}`)).data,
|
||
enabled: !isNew,
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (!isNew && existing.data) {
|
||
const s = existing.data
|
||
setForm({
|
||
date: s.date.slice(0, 10),
|
||
customerId: s.customerId,
|
||
storeId: s.storeId,
|
||
currencyId: s.currencyId,
|
||
payment: s.payment,
|
||
paidAmount: s.paidAmount,
|
||
notes: s.notes ?? '',
|
||
lines: s.lines.map((l) => ({
|
||
productId: l.productId,
|
||
productName: l.productName ?? '',
|
||
productArticle: l.productArticle,
|
||
unitSymbol: l.unitSymbol,
|
||
quantity: l.quantity,
|
||
unitPrice: l.unitPrice,
|
||
discount: l.discount,
|
||
vatPercent: l.vatPercent,
|
||
stockAtStore: l.stockAtStore,
|
||
})),
|
||
})
|
||
}
|
||
}, [isNew, existing.data])
|
||
|
||
useEffect(() => {
|
||
if (isNew) {
|
||
if (!form.storeId && stores.data?.length) {
|
||
const main = stores.data.find((s) => s.isMain) ?? stores.data[0]
|
||
setForm((f) => ({ ...f, storeId: main.id }))
|
||
}
|
||
if (!form.currencyId && currencies.data?.length) {
|
||
const def = org.data?.defaultCurrencyId
|
||
? currencies.data.find((c) => c.id === org.data!.defaultCurrencyId)
|
||
: currencies.data.find((c) => c.code === 'KZT')
|
||
if (def) setForm((f) => ({ ...f, currencyId: def.id }))
|
||
}
|
||
}
|
||
}, [isNew, stores.data, currencies.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId])
|
||
|
||
const isDraft = isNew || existing.data?.status === DemandStatus.Draft
|
||
const isPosted = existing.data?.status === DemandStatus.Posted
|
||
|
||
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice - l.discount
|
||
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
|
||
|
||
const save = useMutation({
|
||
mutationFn: async () => {
|
||
const payload = {
|
||
date: new Date(form.date).toISOString(),
|
||
customerId: form.customerId,
|
||
storeId: form.storeId,
|
||
currencyId: form.currencyId,
|
||
payment: form.payment,
|
||
paidAmount: form.paidAmount,
|
||
notes: form.notes || null,
|
||
lines: form.lines.map((l) => ({
|
||
productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice,
|
||
discount: l.discount, vatPercent: l.vatPercent,
|
||
})),
|
||
}
|
||
if (isNew) return (await api.post<DemandDto>('/api/sales/demands', payload)).data
|
||
await api.put(`/api/sales/demands/${id}`, payload)
|
||
return null
|
||
},
|
||
onSuccess: (created) => {
|
||
qc.invalidateQueries({ queryKey: ['/api/sales/demands'] })
|
||
navigate(created ? `/sales/demands/${created.id}` : `/sales/demands/${id}`)
|
||
},
|
||
onError: (e: Error) => setError(e.message),
|
||
meta: { successMessage: 'Сохранено' },
|
||
})
|
||
|
||
const post = useMutation({
|
||
mutationFn: async () => { await api.post(`/api/sales/demands/${id}/post`) },
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['/api/sales/demands'] })
|
||
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
|
||
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
|
||
existing.refetch()
|
||
},
|
||
onError: (e: Error) => {
|
||
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
|
||
setError(msg)
|
||
},
|
||
meta: { successMessage: 'Проведено' },
|
||
})
|
||
|
||
const unpost = useMutation({
|
||
mutationFn: async () => { await api.post(`/api/sales/demands/${id}/unpost`) },
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['/api/sales/demands'] })
|
||
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
|
||
existing.refetch()
|
||
},
|
||
onError: (e: Error) => setError(e.message),
|
||
meta: { successMessage: 'Снято с проведения' },
|
||
})
|
||
|
||
const remove = useMutation({
|
||
mutationFn: async () => { await api.delete(`/api/sales/demands/${id}`) },
|
||
onSuccess: () => navigate('/sales/demands'),
|
||
onError: (e: Error) => setError(e.message),
|
||
meta: { successMessage: 'Удалено' },
|
||
})
|
||
|
||
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
|
||
|
||
const addLineFromProduct = (p: Product) => {
|
||
setForm({
|
||
...form,
|
||
lines: [...form.lines, {
|
||
productId: p.id,
|
||
productName: p.name,
|
||
productArticle: p.article,
|
||
unitSymbol: p.unitName,
|
||
quantity: 1,
|
||
unitPrice: p.prices?.[0]?.amount ?? 0,
|
||
discount: 0,
|
||
vatPercent: p.vat,
|
||
stockAtStore: null,
|
||
}],
|
||
})
|
||
}
|
||
const updateLine = (i: number, patch: Partial<LineRow>) =>
|
||
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
|
||
const removeLine = (i: number) =>
|
||
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
|
||
|
||
const canSave = !!form.date && !!form.customerId && !!form.storeId && !!form.currencyId
|
||
&& form.lines.length > 0 && isDraft
|
||
|
||
const fractional = org.data?.allowFractionalPrices ?? false
|
||
const moneyFmt = fractional
|
||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||
: { maximumFractionDigits: 0 }
|
||
|
||
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
|
||
// Esc отключается когда открыт ConfirmDialog.
|
||
useShortcuts({
|
||
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
|
||
'Escape': () => navigate('/sales/demands'),
|
||
}, !dialogProps.open)
|
||
|
||
// На редактировании пока тащим документ — показываем скелет.
|
||
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||
|
||
return (
|
||
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<Link to="/sales/demands" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
|
||
<ArrowLeft className="w-5 h-5" />
|
||
</Link>
|
||
<div className="min-w-0">
|
||
<Breadcrumbs items={[
|
||
{ label: 'Продажи' },
|
||
{ label: 'Оптовые отгрузки', to: '/sales/demands' },
|
||
{ label: isNew ? 'Новая отгрузка' : (existing.data?.number || 'Отгрузка') },
|
||
]} />
|
||
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||
{isNew ? 'Новая отгрузка' : existing.data?.number ?? 'Отгрузка'}
|
||
</h1>
|
||
{isPosted && (
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3 flex-shrink-0 items-center">
|
||
{isDraft && !isNew && (
|
||
<Button type="button" variant="danger" size="sm" onClick={async () => {
|
||
if (await confirm({
|
||
title: 'Удалить черновик отгрузки?',
|
||
description: <>Удалить черновик № <strong>{existing.data?.number || '—'}</strong>? Действие необратимо.</>,
|
||
confirmLabel: 'Удалить',
|
||
})) remove.mutate()
|
||
}}>
|
||
<Trash2 className="w-4 h-4" /> Удалить
|
||
</Button>
|
||
)}
|
||
{isDraft && (
|
||
<Button type="submit" disabled={!canSave || save.isPending}>
|
||
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto">
|
||
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
|
||
{error && (
|
||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||
)}
|
||
|
||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||
<Field label="Дата *">
|
||
<DateField required value={form.date || null} disabled={isPosted}
|
||
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
|
||
</Field>
|
||
<Field label="Контрагент *">
|
||
<AsyncSelect
|
||
url="/api/catalog/counterparties"
|
||
value={form.customerId}
|
||
disabled={isPosted}
|
||
onChange={(v) => setForm({ ...form, customerId: v })}
|
||
placeholder="Выберите контрагента…"
|
||
/>
|
||
</Field>
|
||
<Field label="Склад *">
|
||
<Select value={form.storeId} disabled={isPosted}
|
||
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
|
||
<option value="">—</option>
|
||
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||
</Select>
|
||
</Field>
|
||
<Field label="Способ оплаты *">
|
||
<Select value={String(form.payment)} disabled={isPosted}
|
||
onChange={(e) => setForm({ ...form, payment: Number(e.target.value) as DemandPayment })}>
|
||
{Object.entries(demandPaymentLabel).map(([v, lbl]) => (
|
||
<option key={v} value={v}>{lbl}</option>
|
||
))}
|
||
</Select>
|
||
</Field>
|
||
<Field label="Оплачено">
|
||
<MoneyInput value={form.paidAmount} disabled={isPosted}
|
||
allowFractional={fractional}
|
||
onChange={(v) => setForm({ ...form, paidAmount: v ?? 0 })} />
|
||
</Field>
|
||
{org.data?.multiCurrencyEnabled && (
|
||
<Field label="Валюта *">
|
||
<Select value={form.currencyId} disabled={isPosted}
|
||
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
|
||
<option value="">—</option>
|
||
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||
</Select>
|
||
</Field>
|
||
)}
|
||
<Field label="Комментарий" className="md:col-span-3">
|
||
<TextArea rows={2} value={form.notes} disabled={isPosted}
|
||
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||
</Field>
|
||
</div>
|
||
|
||
{!isNew && (
|
||
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
|
||
<Checkbox
|
||
label="Проведено"
|
||
checked={isPosted}
|
||
disabled={post.isPending || unpost.isPending || form.lines.length === 0}
|
||
onChange={async (v) => {
|
||
if (v) {
|
||
if (await confirm({
|
||
title: 'Провести отгрузку?',
|
||
description: 'Товар спишется со склада в адрес покупателя.',
|
||
confirmLabel: 'Провести',
|
||
tone: 'warning',
|
||
})) post.mutate()
|
||
} else {
|
||
if (await confirm({
|
||
title: 'Снять проведение?',
|
||
description: 'Товар вернётся на склад.',
|
||
confirmLabel: 'Снять',
|
||
tone: 'warning',
|
||
})) unpost.mutate()
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="font-medium text-slate-900 dark:text-slate-100">Позиции</h2>
|
||
{!isPosted && (
|
||
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
|
||
<Plus className="w-3.5 h-3.5" /> Добавить из справочника
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{form.lines.length === 0 ? (
|
||
<div className="text-sm text-slate-500 dark:text-slate-400 py-6 text-center">Нет позиций.</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="text-left">
|
||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||
<th className="py-2 pr-3 font-medium text-xs uppercase text-slate-500 dark:text-slate-400">Товар</th>
|
||
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 dark:text-slate-400 w-[80px]">Ед.</th>
|
||
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 dark:text-slate-400 w-[100px] text-right">Остаток</th>
|
||
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 dark:text-slate-400 w-[110px] text-right">Кол-во</th>
|
||
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 dark:text-slate-400 w-[130px] text-right">Цена</th>
|
||
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 dark:text-slate-400 w-[110px] text-right">Скидка</th>
|
||
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 dark:text-slate-400 w-[80px] text-right">НДС</th>
|
||
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 dark:text-slate-400 w-[140px] text-right">Сумма</th>
|
||
<th className="w-8"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{form.lines.map((l, i) => (
|
||
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
|
||
<td className="py-2 pr-3">
|
||
<div className="font-medium text-slate-800 dark:text-slate-100">{l.productName || '(без названия)'}</div>
|
||
{l.productArticle && <div className="text-xs text-slate-500 dark:text-slate-400">{l.productArticle}</div>}
|
||
</td>
|
||
<td className="py-2 px-3 text-slate-500 dark:text-slate-400">{l.unitSymbol ?? '—'}</td>
|
||
<td className="py-2 px-3 text-right font-mono text-slate-500 dark:text-slate-400">
|
||
{l.stockAtStore != null ? l.stockAtStore.toLocaleString('ru') : '—'}
|
||
</td>
|
||
<td className="py-2 px-3 text-right">
|
||
<NumberInput value={l.quantity} disabled={isPosted}
|
||
onChange={(v) => updateLine(i, { quantity: v ?? 0 })} />
|
||
</td>
|
||
<td className="py-2 px-3 text-right">
|
||
<MoneyInput value={l.unitPrice} disabled={isPosted}
|
||
allowFractional={fractional}
|
||
onChange={(v) => updateLine(i, { unitPrice: v ?? 0 })} />
|
||
</td>
|
||
<td className="py-2 px-3 text-right">
|
||
<MoneyInput value={l.discount} disabled={isPosted}
|
||
allowFractional={fractional}
|
||
onChange={(v) => updateLine(i, { discount: v ?? 0 })} />
|
||
</td>
|
||
<td className="py-2 px-3 text-right">
|
||
<NumberInput value={l.vatPercent} disabled={isPosted}
|
||
onChange={(v) => updateLine(i, { vatPercent: v ?? 0 })} />
|
||
</td>
|
||
<td className="py-2 px-3 text-right font-mono">
|
||
{lineTotal(l).toLocaleString('ru', moneyFmt)}
|
||
</td>
|
||
<td className="py-2 px-1">
|
||
{!isPosted && (
|
||
<button type="button" onClick={() => removeLine(i)}
|
||
className="text-slate-400 hover:text-red-600">
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
<tr className="font-medium">
|
||
<td className="py-3 pr-3" colSpan={7}>Итого</td>
|
||
<td className="py-3 px-3 text-right font-mono">
|
||
{grandTotal.toLocaleString('ru', moneyFmt)} {existing.data?.currencyCode ?? ''}
|
||
</td>
|
||
<td />
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<ProductPicker
|
||
open={pickerOpen}
|
||
onClose={() => setPickerOpen(false)}
|
||
onPick={(p) => { addLineFromProduct(p); setPickerOpen(false) }}
|
||
/>
|
||
<ConfirmDialog {...dialogProps} />
|
||
</form>
|
||
)
|
||
}
|