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
Система теперь корректно работает на узких экранах (<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>
377 lines
17 KiB
TypeScript
377 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|