food-market/src/food-market.web/src/pages/SupplyEditPage.tsx
nns 481dcdf826
Some checks failed
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Has been cancelled
Docker Images / API image (push) Successful in 33s
Docker Images / Web image (push) Successful in 25s
Docker Images / Deploy stage (push) Successful in 18s
ui(mobile): адаптация под смартфоны — drawer-меню, grid, модалка
Система теперь корректно работает на узких экранах (<768px):

- AppLayout: на мобиле фиксированный sidebar заменён hamburger-меню
  (Menu icon) + off-canvas drawer с overlay. На md+ — прежний sidebar.
- ProductsPage: дерево групп тоже превращается в drawer на мобиле,
  кнопка «Группы» рядом с заголовком; фильтры flex-wrap.
- Modal: на мобиле (<sm) разворачивается на весь экран (items-stretch,
  min-h-full, убраны скругления и верхний отступ).
- DataTable: мин-ширина 640px + whitespace-nowrap в заголовках, уменьшен
  горизонтальный padding на мобиле. Родительский overflow-auto даёт
  плавный горизонтальный скролл.
- PageHeader/ListPageShell: flex-wrap, меньший padding на мобиле.
- SearchBar: flex-1 на узких (занимает доступное место), фикс 256px на sm+.
- ProductEditPage Grid helper: 3/4 колонки теперь grid-cols-1 sm: 2
  md: 3/4 — поля не слипаются на телефоне.
- ProductEditPage/Supply/RetailSale/Dashboard/OrganizationSettings:
  отступы p-3 sm:p-6, grid grid-cols-2 на страну/валюту → 1 col на мобиле.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:17:56 +05:00

377 lines
17 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, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select } from '@/components/Field'
import { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
interface LineRow {
id?: string
productId: string
productName: string
productArticle: string | null
unitName: string | null
quantity: number
unitPrice: number
}
interface Form {
date: string
supplierId: string
storeId: string
currencyId: string
supplierInvoiceNumber: string
supplierInvoiceDate: string
notes: string
lines: LineRow[]
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const emptyForm: Form = {
date: todayIso(),
supplierId: '', storeId: '', currencyId: '',
supplierInvoiceNumber: '', supplierInvoiceDate: '',
notes: '',
lines: [],
}
export function SupplyEditPage() {
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 suppliers = useSuppliers()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
const existing = useQuery({
queryKey: ['/api/purchases/supplies', id],
queryFn: async () => (await api.get<SupplyDto>(`/api/purchases/supplies/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 10),
supplierId: s.supplierId,
storeId: s.storeId,
currencyId: s.currencyId,
supplierInvoiceNumber: s.supplierInvoiceNumber ?? '',
supplierInvoiceDate: s.supplierInvoiceDate ? s.supplierInvoiceDate.slice(0, 10) : '',
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
id: l.id ?? undefined,
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitName: l.unitName,
quantity: l.quantity,
unitPrice: l.unitPrice,
})),
})
}
}, [isNew, existing.data])
useEffect(() => {
// Prefill defaults for new document.
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 }))
}
if (!form.supplierId && suppliers.data?.length) {
setForm((f) => ({ ...f, supplierId: suppliers.data![0].id }))
}
}
}, [isNew, stores.data, currencies.data, suppliers.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId, form.supplierId])
const isDraft = isNew || existing.data?.status === SupplyStatus.Draft
const isPosted = existing.data?.status === SupplyStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
supplierId: form.supplierId,
storeId: form.storeId,
currencyId: form.currencyId,
supplierInvoiceNumber: form.supplierInvoiceNumber || null,
supplierInvoiceDate: form.supplierInvoiceDate ? new Date(form.supplierInvoiceDate).toISOString() : null,
notes: form.notes || null,
lines: form.lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice })),
}
if (isNew) {
return (await api.post<SupplyDto>('/api/purchases/supplies', payload)).data
}
await api.put(`/api/purchases/supplies/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) },
onSuccess: () => navigate('/purchases/supplies'),
onError: (e: Error) => setError(e.message),
})
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,
unitName: p.unitName,
quantity: 1,
unitPrice: p.purchasePrice ?? 0,
}],
})
}
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.supplierId && !!form.storeId && !!form.currencyId && isDraft
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
{/* Sticky top bar */}
<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="/purchases/supplies" 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">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'}
</h1>
<p className="text-xs text-slate-500">
{isPosted
? <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-2 flex-shrink-0">
{isPosted && (
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
<Undo2 className="w-4 h-4" /> Отменить проведение
</Button>
)}
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик приёмки?')) remove.mutate() }}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
{isDraft && (
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
)}
{isDraft && !isNew && (
<Button type="button" onClick={() => post.mutate()} disabled={post.isPending || form.lines.length === 0}>
<CheckCircle className="w-4 h-4" /> {post.isPending ? 'Провожу…' : 'Провести'}
</Button>
)}
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
{error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<Section title="Реквизиты документа">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата">
<TextInput type="date" value={form.date} disabled={isPosted}
onChange={(e) => setForm({ ...form, date: e.target.value })} />
</Field>
<Field label="Поставщик *">
<Select value={form.supplierId} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</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>
{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="№ накладной поставщика">
<TextInput value={form.supplierInvoiceNumber} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierInvoiceNumber: e.target.value })} />
</Field>
<Field label="Дата накладной">
<TextInput type="date" value={form.supplierInvoiceDate} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierInvoiceDate: e.target.value })} />
</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>
</Section>
<Section
title="Позиции"
action={!isPosted && (
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
<Plus className="w-3.5 h-3.5" /> Добавить товар
</Button>
)}
>
{form.lines.length === 0 ? (
<div className="text-sm text-slate-400 py-4 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 tracking-wide text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[90px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Количество</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Цена</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[160px] text-right">Сумма</th>
<th className="py-2 pl-3 w-[40px]"></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">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono"
value={l.quantity}
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted}
className="text-right font-mono"
value={l.unitPrice}
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
</td>
<td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
</td>
<td className="py-2 pl-3">
{!isPosted && (
<button type="button" onClick={() => removeLine(i)} className="text-slate-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={4} className="py-3 pr-3 text-right text-sm font-semibold text-slate-600 dark:text-slate-300">
Итого:
</td>
<td className="py-3 px-3 text-right font-mono text-lg font-bold">
{grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}
{' '}
<span className="text-sm text-slate-500">
{currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}
</span>
</td>
<td />
</tr>
</tfoot>
</table>
</div>
)}
</Section>
</div>
</div>
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
</form>
)
}
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{action}
</header>
<div className="p-5">{children}</div>
</section>
)
}