food-market/src/food-market.web/src/pages/DemandEditPage.tsx
nns 786dacb081
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
feat(s10-4): dark mode полировка + Cmd+K палитра + аудит-spec
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>
2026-06-06 01:30:41 +05:00

446 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}