feat(org-settings): Country↔Currency, Organization.DefaultCurrency/MultiCurrency/DefaultVat + UI настроек
Миграция 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:
parent
a8b3ef40ce
commit
337e790eab
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
21
src/food-market.web/src/lib/useOrgSettings.ts
Normal file
21
src/food-market.web/src/lib/useOrgSettings.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
116
src/food-market.web/src/pages/OrganizationSettingsPage.tsx
Normal file
116
src/food-market.web/src/pages/OrganizationSettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
{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>
|
||||
{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)}
|
||||
|
|
|
|||
|
|
@ -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,6 +258,7 @@ export function RetailSaleEditPage() {
|
|||
{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 })}>
|
||||
|
|
@ -260,6 +266,7 @@ export function RetailSaleEditPage() {
|
|||
{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 })}>
|
||||
|
|
|
|||
|
|
@ -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,6 +256,7 @@ export function SupplyEditPage() {
|
|||
{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 })}>
|
||||
|
|
@ -259,6 +264,7 @@ export function SupplyEditPage() {
|
|||
{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 })} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue