feat(org-settings): Country↔Currency, Organization.DefaultCurrency/MultiCurrency/DefaultVat + UI настроек
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 27s
CI / Web (React + Vite) (push) Successful in 24s
Docker Images / API image (push) Successful in 38s
Docker Images / Web image (push) Successful in 24s
Docker Images / Deploy stage (push) Successful in 18s

Миграция Phase4_CountryCurrencyOrgDefaults:
- countries.DefaultCurrencyId (FK → currencies)
- organizations.DefaultCurrencyId, MultiCurrencyEnabled, DefaultVat
- Seed: KZ→KZT, RU→RUB, BY→BYN, US→USD, DE→EUR, CN→CNY, TR→TRY
- Default для org: KZT, vat=16

Backend:
- Organization сущность получила DefaultCurrency/MultiCurrencyEnabled/DefaultVat.
- OrganizationSettingsController: GET/PUT /api/organization/settings.
- DevDataSeeder при создании/backfill орга выставляет KZT + vat=16.

Web:
- /settings/organization: форма с выбором страны (авто-подтягивает валюту),
  чекбоксом multi-currency, ставкой НДС по умолчанию.
- useOrgSettings() хук.
- SupplyEditPage / RetailSaleEditPage / ProductEditPage: select валюты
  показывается только если multiCurrencyEnabled=true, иначе
  подтягивается DefaultCurrency организации и рисуется символ валюты
  справа от цены.
- ProductEditPage при создании нового товара берёт VAT из org.DefaultVat.
- В sidebar добавлен раздел 'Настройки → Организация', убран
  Ставки НДС (сущность удалена раньше).
This commit is contained in:
nurdotnet 2026-04-24 11:03:25 +05:00
parent d86b6ba742
commit 773ecde6ba
16 changed files with 2291 additions and 38 deletions

View file

@ -0,0 +1,79 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Organizations;
[ApiController]
[Authorize]
[Route("api/organization")]
public class OrganizationSettingsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public OrganizationSettingsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public record OrgSettingsDto(
Guid Id,
string Name,
string CountryCode,
Guid? DefaultCurrencyId,
string? DefaultCurrencyCode,
string? DefaultCurrencySymbol,
bool MultiCurrencyEnabled,
int DefaultVat);
public record OrgSettingsInput(
string Name,
string CountryCode,
Guid? DefaultCurrencyId,
bool MultiCurrencyEnabled,
int DefaultVat);
[HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var o = await _db.Organizations
.Include(o => o.DefaultCurrency)
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (o is null) return NotFound();
return Project(o);
}
[HttpPut("settings"), Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var o = await _db.Organizations
.Include(o => o.DefaultCurrency)
.FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (o is null) return NotFound();
o.Name = input.Name;
o.CountryCode = input.CountryCode;
o.DefaultCurrencyId = input.DefaultCurrencyId;
o.MultiCurrencyEnabled = input.MultiCurrencyEnabled;
o.DefaultVat = input.DefaultVat;
await _db.SaveChangesAsync(ct);
// Re-read чтобы подтянуть DefaultCurrency.
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
return Project(o);
}
private static OrgSettingsDto Project(foodmarket.Domain.Organizations.Organization o) => new(
o.Id, o.Name, o.CountryCode,
o.DefaultCurrencyId,
o.DefaultCurrency?.Code,
o.DefaultCurrency?.Symbol,
o.MultiCurrencyEnabled,
o.DefaultVat);
}

View file

@ -38,6 +38,7 @@ public async Task StartAsync(CancellationToken ct)
}
}
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
if (demoOrg is null)
{
@ -48,11 +49,20 @@ public async Task StartAsync(CancellationToken ct)
Bin = "000000000000",
Address = "Алматы, ул. Пример 1",
Phone = "+7 (777) 000-00-00",
Email = "demo@food-market.local"
Email = "demo@food-market.local",
DefaultCurrencyId = kzt?.Id,
DefaultVat = 16,
};
db.Organizations.Add(demoOrg);
await db.SaveChangesAsync(ct);
}
else if (demoOrg.DefaultCurrencyId is null && kzt is not null)
{
// backfill для существующей организации на стенде
demoOrg.DefaultCurrencyId = kzt.Id;
if (demoOrg.DefaultVat == 0) demoOrg.DefaultVat = 16;
await db.SaveChangesAsync(ct);
}
await SeedTenantReferencesAsync(db, demoOrg.Id, ct);

View file

@ -8,4 +8,8 @@ public class Country : Entity
public string Code { get; set; } = null!; // ISO 3166-1 alpha-2, e.g. "KZ"
public string Name { get; set; } = null!;
public int SortOrder { get; set; }
/// <summary>Валюта по умолчанию для этой страны — при выборе страны в настройках
/// организации её валюта подтягивается автоматически.</summary>
public Guid? DefaultCurrencyId { get; set; }
public Currency? DefaultCurrency { get; set; }
}

View file

@ -1,3 +1,4 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
@ -15,4 +16,17 @@ public class Organization : Entity
/// <summary>Персональный API-токен MoySklad. Храним per-organization чтобы
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
public string? MoySkladToken { get; set; }
/// <summary>Валюта организации по умолчанию. Если MultiCurrencyEnabled=false,
/// в UI выбор валюты скрыт — всё в этой валюте.</summary>
public Guid? DefaultCurrencyId { get; set; }
public Currency? DefaultCurrency { get; set; }
/// <summary>Разрешены ли продажи/закупки в нескольких валютах. По умолчанию
/// false — тогда UI не предлагает выбор валюты, всё в DefaultCurrency.</summary>
public bool MultiCurrencyEnabled { get; set; }
/// <summary>Ставка НДС по умолчанию для новых товаров (KZ=16%, RU=20%).
/// Само значение применяется к товару при создании; пользователь может менять.</summary>
public int DefaultVat { get; set; } = 16;
}

View file

@ -70,6 +70,7 @@ protected override void OnModelCreating(ModelBuilder builder)
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
b.Property(o => o.Bin).HasMaxLength(20);
b.Property(o => o.MoySkladToken).HasMaxLength(200);
b.HasOne(o => o.DefaultCurrency).WithMany().HasForeignKey(o => o.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(o => o.Name);
});

View file

@ -27,6 +27,7 @@ private static void ConfigureCountry(EntityTypeBuilder<Country> b)
b.ToTable("countries");
b.Property(x => x.Code).HasMaxLength(2).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.HasOne(x => x.DefaultCurrency).WithMany().HasForeignKey(x => x.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => x.Code).IsUnique();
}

View file

@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Country ↔ Currency связка + дефолты организации:
/// - countries.DefaultCurrencyId (nullable FK → currencies.Id)
/// - organizations.DefaultCurrencyId (FK → currencies.Id)
/// - organizations.MultiCurrencyEnabled (bool, default false)
/// - organizations.DefaultVat (int, default 16)
/// Seed: KZ→KZT, RU→RUB; org → KZ+KZT.</summary>
public partial class Phase4_CountryCurrencyOrgDefaults : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<Guid>(
name: "DefaultCurrencyId", schema: "public", table: "countries",
type: "uuid", nullable: true);
b.AddColumn<Guid>(
name: "DefaultCurrencyId", schema: "public", table: "organizations",
type: "uuid", nullable: true);
b.AddColumn<bool>(
name: "MultiCurrencyEnabled", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
b.AddColumn<int>(
name: "DefaultVat", schema: "public", table: "organizations",
type: "integer", nullable: false, defaultValue: 16);
b.CreateIndex(
name: "IX_countries_DefaultCurrencyId", schema: "public",
table: "countries", column: "DefaultCurrencyId");
b.CreateIndex(
name: "IX_organizations_DefaultCurrencyId", schema: "public",
table: "organizations", column: "DefaultCurrencyId");
b.AddForeignKey(
name: "FK_countries_currencies_DefaultCurrencyId",
schema: "public", table: "countries", column: "DefaultCurrencyId",
principalSchema: "public", principalTable: "currencies", principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
b.AddForeignKey(
name: "FK_organizations_currencies_DefaultCurrencyId",
schema: "public", table: "organizations", column: "DefaultCurrencyId",
principalSchema: "public", principalTable: "currencies", principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
// Backfill: привяжем валюты к странам по ISO-коду.
b.Sql("""
UPDATE public.countries SET "DefaultCurrencyId" = c."Id"
FROM public.currencies c
WHERE (public.countries."Code" = 'KZ' AND c."Code" = 'KZT')
OR (public.countries."Code" = 'RU' AND c."Code" = 'RUB')
OR (public.countries."Code" = 'BY' AND c."Code" = 'BYN')
OR (public.countries."Code" = 'US' AND c."Code" = 'USD')
OR (public.countries."Code" = 'DE' AND c."Code" = 'EUR')
OR (public.countries."Code" = 'CN' AND c."Code" = 'CNY')
OR (public.countries."Code" = 'TR' AND c."Code" = 'TRY');
""");
// Дефолт для организации — KZT, если существует.
b.Sql("""
UPDATE public.organizations SET "DefaultCurrencyId" = c."Id"
FROM public.currencies c
WHERE c."Code" = 'KZT' AND public.organizations."DefaultCurrencyId" IS NULL;
""");
}
protected override void Down(MigrationBuilder b)
{
b.DropForeignKey(name: "FK_countries_currencies_DefaultCurrencyId", schema: "public", table: "countries");
b.DropForeignKey(name: "FK_organizations_currencies_DefaultCurrencyId", schema: "public", table: "organizations");
b.DropIndex(name: "IX_countries_DefaultCurrencyId", schema: "public", table: "countries");
b.DropIndex(name: "IX_organizations_DefaultCurrencyId", schema: "public", table: "organizations");
b.DropColumn(name: "DefaultCurrencyId", schema: "public", table: "countries");
b.DropColumn(name: "DefaultCurrencyId", schema: "public", table: "organizations");
b.DropColumn(name: "MultiCurrencyEnabled", schema: "public", table: "organizations");
b.DropColumn(name: "DefaultVat", schema: "public", table: "organizations");
}
}
}

View file

@ -434,6 +434,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DefaultCurrencyId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@ -450,6 +453,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("Code")
.IsUnique();
b.HasIndex("DefaultCurrencyId");
b.ToTable("countries", "public");
});
@ -1071,6 +1076,12 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<string>("Email")
.HasColumnType("text");
b.Property<Guid?>("DefaultCurrencyId")
.HasColumnType("uuid");
b.Property<int>("DefaultVat")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
@ -1078,6 +1089,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("MultiCurrencyEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@ -1091,6 +1105,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasKey("Id");
b.HasIndex("DefaultCurrencyId");
b.HasIndex("Name");
b.ToTable("organizations", "public");

View file

@ -13,6 +13,7 @@ import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
import { ProductsPage } from '@/pages/ProductsPage'
import { ProductEditPage } from '@/pages/ProductEditPage'
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
import { OrganizationSettingsPage } from '@/pages/OrganizationSettingsPage'
import { StockPage } from '@/pages/StockPage'
import { StockMovementsPage } from '@/pages/StockMovementsPage'
import { SuppliesPage } from '@/pages/SuppliesPage'
@ -60,6 +61,7 @@ export default function App() {
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
<Route path="/settings/organization" element={<OrganizationSettingsPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -4,9 +4,9 @@ import { api } from '@/lib/api'
import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils'
import {
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
LayoutDashboard, Package, FolderTree, Ruler, Tag,
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
Boxes, History, TruckIcon, ShoppingCart,
Boxes, History, TruckIcon, ShoppingCart, Settings,
} from 'lucide-react'
import { Logo } from './Logo'
@ -26,7 +26,6 @@ const nav = [
{ to: '/catalog/products', icon: Package, label: 'Товары' },
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
{ to: '/catalog/vat-rates', icon: Percent, label: 'Ставки НДС' },
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
]},
{ group: 'Контрагенты', items: [
@ -53,6 +52,9 @@ const nav = [
{ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
]},
{ group: 'Настройки', items: [
{ to: '/settings/organization', icon: Settings, label: 'Организация' },
]},
] as const
export function AppLayout() {

View file

@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
export interface OrgSettings {
id: string
name: string
countryCode: string
defaultCurrencyId: string | null
defaultCurrencyCode: string | null
defaultCurrencySymbol: string | null
multiCurrencyEnabled: boolean
defaultVat: number
}
export function useOrgSettings() {
return useQuery({
queryKey: ['/api/organization/settings'],
queryFn: async () => (await api.get<OrgSettings>('/api/organization/settings')).data,
staleTime: 5 * 60 * 1000,
})
}

View file

@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/Button'
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { useCurrencies, useCountries } from '@/lib/useLookups'
import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings'
const vatChoices = [0, 10, 12, 16, 20]
export function OrganizationSettingsPage() {
const qc = useQueryClient()
const settings = useOrgSettings()
const currencies = useCurrencies()
const countries = useCountries()
const [form, setForm] = useState<OrgSettings | null>(null)
useEffect(() => { if (settings.data && !form) setForm(settings.data) }, [settings.data, form])
// При смене страны подтягиваем её дефолтную валюту.
const onCountryChange = (countryCode: string) => {
if (!form) return
const country = countries.data?.find((c) => c.code === countryCode)
const fallbackByCode: Record<string, string | undefined> = { KZ: 'KZT', RU: 'RUB', BY: 'BYN', US: 'USD' }
const targetCode = fallbackByCode[countryCode]
const currency = targetCode ? currencies.data?.find((c) => c.code === targetCode) : undefined
setForm({
...form,
countryCode,
defaultCurrencyId: currency?.id ?? form.defaultCurrencyId,
defaultCurrencyCode: currency?.code ?? form.defaultCurrencyCode,
defaultCurrencySymbol: currency?.symbol ?? form.defaultCurrencySymbol,
})
void country // reserved for future use (sortOrder etc.)
}
const save = useMutation({
mutationFn: async () => {
if (!form) return
const payload = {
name: form.name,
countryCode: form.countryCode,
defaultCurrencyId: form.defaultCurrencyId,
multiCurrencyEnabled: form.multiCurrencyEnabled,
defaultVat: form.defaultVat,
}
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
},
onSuccess: (d) => {
if (d) setForm(d)
qc.invalidateQueries({ queryKey: ['/api/organization/settings'] })
},
})
if (!form) return <div className="p-6 text-sm text-slate-500">Загрузка</div>
return (
<div className="h-full overflow-auto">
<div className="p-6 max-w-2xl">
<PageHeader title="Настройки организации" description="Страна, валюта, ставка НДС по умолчанию." />
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<Field label="Название организации">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Страна">
<Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}>
{countries.data?.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
</Select>
</Field>
<Field label="Валюта по умолчанию">
<Select
value={form.defaultCurrencyId ?? ''}
onChange={(e) => {
const id = e.target.value
const c = currencies.data?.find((x) => x.id === id)
setForm({ ...form, defaultCurrencyId: id || null, defaultCurrencyCode: c?.code ?? null, defaultCurrencySymbol: c?.symbol ?? null })
}}
>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code} ({c.symbol})</option>)}
</Select>
</Field>
</div>
<Checkbox
label="Разрешить продажи и закупки в нескольких валютах"
checked={form.multiCurrencyEnabled}
onChange={(v) => setForm({ ...form, multiCurrencyEnabled: v })}
/>
<p className="text-xs text-slate-500 -mt-2">
Если выключено в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию.
</p>
<Field label="Ставка НДС по умолчанию для новых товаров">
<Select value={form.defaultVat} onChange={(e) => setForm({ ...form, defaultVat: Number(e.target.value) })}>
{vatChoices.map((v) => <option key={v} value={v}>{v}%</option>)}
</Select>
</Field>
</section>
<div className="mt-4 flex gap-3 items-center">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
{save.isSuccess && <span className="text-sm text-emerald-600">Сохранено</span>}
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
</div>
</div>
</div>
)
}

View file

@ -8,6 +8,7 @@ import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field
import {
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
} from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, type Product } from '@/lib/types'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
@ -63,6 +64,7 @@ export function ProductEditPage() {
const countries = useCountries()
const currencies = useCurrencies()
const priceTypes = usePriceTypes()
const org = useOrgSettings()
const suppliers = useSuppliers()
const existing = useQuery({
@ -100,9 +102,16 @@ export function ProductEditPage() {
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.[0]?.id ?? '' }))
}
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' }))
const def = org.data?.defaultCurrencyId
? currencies.data?.find(c => c.id === org.data!.defaultCurrencyId)
: currencies.data?.find(c => c.code === 'KZT')
setForm((f) => ({ ...f, purchaseCurrencyId: def?.id ?? currencies.data?.[0]?.id ?? '' }))
}
}, [isNew, units.data, currencies.data, form.unitOfMeasureId, form.purchaseCurrencyId])
// Default VAT для нового товара берём из настроек организации.
if (isNew && org.data?.defaultVat !== undefined && form.vat === 16 && org.data.defaultVat !== 16) {
setForm((f) => ({ ...f, vat: org.data!.defaultVat }))
}
}, [isNew, units.data, currencies.data, org.data?.defaultCurrencyId, org.data?.defaultVat, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat])
const save = useMutation({
mutationFn: async () => {
@ -278,12 +287,14 @@ export function ProductEditPage() {
<Field label="Закупочная цена">
<TextInput type="number" step="0.01" value={form.purchasePrice} onChange={(e) => setForm({ ...form, purchasePrice: e.target.value })} />
</Field>
<Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
{org.data?.multiCurrencyEnabled && (
<Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
)}
</Grid>
</Section>
@ -308,19 +319,24 @@ export function ProductEditPage() {
<div className="space-y-2">
{form.prices.map((p, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<div className={org.data?.multiCurrencyEnabled ? 'col-span-6' : 'col-span-8'}>
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}>
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select>
</div>
<div className="col-span-3">
<div className="col-span-3 flex items-center gap-2">
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
{!org.data?.multiCurrencyEnabled && (
<span className="text-sm text-slate-500">{org.data?.defaultCurrencySymbol ?? ''}</span>
)}
</div>
<div className="col-span-2">
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</div>
{org.data?.multiCurrencyEnabled && (
<div className="col-span-2">
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</div>
)}
<button
type="button"
onClick={() => removePrice(i)}

View file

@ -7,6 +7,7 @@ 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 { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
interface LineRow {
@ -51,6 +52,7 @@ export function RetailSaleEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const customers = useSuppliers() // we re-use the suppliers hook (returns all counterparties) — fine for MVP
const [form, setForm] = useState<Form>(empty)
@ -95,10 +97,13 @@ export function RetailSaleEditPage() {
setForm((f) => ({ ...f, storeId: stores.data!.find((s) => s.isMain)?.id ?? stores.data![0].id }))
}
if (!form.currencyId && currencies.data?.length) {
setForm((f) => ({ ...f, currencyId: currencies.data!.find((c) => c.code === 'KZT')?.id ?? currencies.data![0].id }))
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, form.storeId, form.currencyId])
}, [isNew, stores.data, currencies.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId])
const isDraft = isNew || existing.data?.status === RetailSaleStatus.Draft
const isPosted = existing.data?.status === RetailSaleStatus.Posted
@ -253,13 +258,15 @@ export function RetailSaleEditPage() {
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<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>
{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="Покупатель (опц.)">
<Select value={form.customerId} disabled={isPosted}
onChange={(e) => setForm({ ...form, customerId: e.target.value })}>

View file

@ -7,6 +7,7 @@ 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 {
@ -48,6 +49,7 @@ export function SupplyEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const suppliers = useSuppliers()
const [form, setForm] = useState<Form>(emptyForm)
@ -92,14 +94,16 @@ export function SupplyEditPage() {
setForm((f) => ({ ...f, storeId: main.id }))
}
if (!form.currencyId && currencies.data?.length) {
const kzt = currencies.data.find((c) => c.code === 'KZT') ?? currencies.data[0]
setForm((f) => ({ ...f, currencyId: kzt.id }))
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, form.storeId, form.currencyId, form.supplierId])
}, [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
@ -252,13 +256,15 @@ export function SupplyEditPage() {
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<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>
{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 })} />