Hosting-Produkte: Staffelpreise nach Laufzeit (1/3/6/12/24 Monate)
This commit is contained in:
parent
e0fd57532d
commit
5efe37ecc0
12 changed files with 145 additions and 28 deletions
|
|
@ -15,11 +15,16 @@ import { canInOrg } from '../../core/policy.js';
|
|||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const int = (max: number) => z.number().int().min(0).max(max);
|
||||
const termPriceSchema = z.array(z.object({ termMonths: z.number().int().min(1).max(120), recurringCents: int(100_000_00) })).max(12)
|
||||
.refine((l) => new Set(l.map((t) => t.termMonths)).size === l.length, 'Jede Laufzeit darf nur einmal vorkommen');
|
||||
const versionSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'),
|
||||
setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']),
|
||||
termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30),
|
||||
provisioning: z.record(z.string(), z.unknown()).default({}),
|
||||
// Staffelpreise nach Laufzeit (optional): z. B. 1/3/6/12/24 Monate mit je eigenem Gesamtpreis für die Laufzeit.
|
||||
// Wählt ein Kunde/Personal bei der Bestellung eine Laufzeit daraus, gilt deren Preis statt recurringCents/billingInterval.
|
||||
termPrices: termPriceSchema.default([]),
|
||||
});
|
||||
type VersionIn = z.infer<typeof versionSchema>;
|
||||
import { CUSTOMER_ACTIONS } from '../../core/actions.js';
|
||||
|
|
@ -63,9 +68,11 @@ async function insertVersion(c: Parameters<typeof run>[2], productId: string, v:
|
|||
`INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c);
|
||||
for (const t of v.termPrices) await run('INSERT INTO product_term_prices (id, product_version_id, term_months, recurring_cents) VALUES (?,?,?,?)', [randomUUID(), id, t.termMonths, t.recurringCents], c);
|
||||
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
|
||||
return id;
|
||||
}
|
||||
const termPricesFor = (versionId: string | null) => versionId ? query('SELECT term_months, recurring_cents FROM product_term_prices WHERE product_version_id = ? ORDER BY term_months', [versionId]) : Promise.resolve([]);
|
||||
interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn }
|
||||
/** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */
|
||||
async function createProduct(b: NewProduct, actorId: string): Promise<string> {
|
||||
|
|
@ -124,7 +131,8 @@ export const catalogModule: KcModule = {
|
|||
});
|
||||
app.get('/admin/products', async (req) => {
|
||||
requirePermission(req, 'products.read');
|
||||
return (await query(`${productSelect} ORDER BY p.created_at DESC`)).map(productView);
|
||||
const rows = await query(`${productSelect} ORDER BY p.created_at DESC`);
|
||||
return Promise.all(rows.map(async (r) => ({ ...productView(r), termPrices: (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, recurringCents: t.recurring_cents })) })));
|
||||
});
|
||||
app.get('/admin/products/:id', async (req) => {
|
||||
requirePermission(req, 'products.read');
|
||||
|
|
@ -132,7 +140,8 @@ export const catalogModule: KcModule = {
|
|||
const r = await one(`${productSelect} WHERE p.id = ?`, [id]);
|
||||
if (!r) throw notFound();
|
||||
const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]);
|
||||
return { ...productView(r), versions: versions.map(versionView) };
|
||||
const termPrices = (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, recurringCents: t.recurring_cents }));
|
||||
return { ...productView(r), termPrices, versions: versions.map(versionView) };
|
||||
});
|
||||
app.post('/admin/products', async (req) => {
|
||||
const a = requirePermission(req, 'products.write');
|
||||
|
|
@ -217,7 +226,7 @@ export const catalogModule: KcModule = {
|
|||
const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning;
|
||||
if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef);
|
||||
const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft',
|
||||
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning } }, a.user.id);
|
||||
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning, termPrices: [] } }, a.user.id);
|
||||
created.push({ sku, id });
|
||||
} catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); }
|
||||
}
|
||||
|
|
@ -264,10 +273,15 @@ export const catalogModule: KcModule = {
|
|||
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
|
||||
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
|
||||
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
|
||||
return rows.map((r) => {
|
||||
return Promise.all(rows.map(async (r) => {
|
||||
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
|
||||
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days };
|
||||
});
|
||||
// Staffelpreise nach Laufzeit (optional): je gewählter Laufzeit ein eigener Gesamtpreis statt des Basispreises.
|
||||
const tiers = (await termPricesFor(r.vid ?? null)).map((t: any) => ({
|
||||
termMonths: t.term_months,
|
||||
price: calculatePrice({ basis: r.price_basis, setupCents: 0, recurringCents: t.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 }).recurring,
|
||||
}));
|
||||
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, termPrices: tiers };
|
||||
}));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -147,13 +147,20 @@ export const invoicesModule: KcModule = {
|
|||
/** Vorschlag aus dem aktuellen Katalogpreis eines Produkts (Einrichtung und/oder wiederkehrender Preis als eigene Positionen). */
|
||||
app.get('/invoices/suggest-from-product/:productId', async (req) => {
|
||||
const a = requirePermission(req, 'invoices.write'); const { productId } = z.object({ productId: z.string().uuid() }).parse(req.params);
|
||||
const p = await one('SELECT p.*, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [productId]);
|
||||
const q = z.object({ termMonths: z.coerce.number().int().min(1).max(120).optional() }).parse(req.query);
|
||||
const p = await one('SELECT p.*, v.id AS vid, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [productId]);
|
||||
if (!p) throw notFound();
|
||||
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: 1, discountBp: 0 });
|
||||
let recurringCents = p.recurring_cents; let label = p.name;
|
||||
if (q.termMonths !== undefined) {
|
||||
const tier = await one('SELECT recurring_cents FROM product_term_prices WHERE product_version_id = ? AND term_months = ?', [p.vid, q.termMonths]);
|
||||
if (!tier) throw badRequest('Diese Laufzeit wird für dieses Produkt nicht angeboten', 'TERM_NOT_AVAILABLE');
|
||||
recurringCents = tier.recurring_cents; label = `${p.name} (${q.termMonths} Monate)`;
|
||||
}
|
||||
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: 1, discountBp: 0 });
|
||||
const items = [];
|
||||
if (price.setup.net > 0) items.push({ description: `Einrichtung: ${p.name}`, quantity: 1, unitPriceNetCents: price.setup.net, taxBp: p.tax_bp });
|
||||
if (price.recurring.net > 0) items.push({ description: p.name, quantity: 1, unitPriceNetCents: price.recurring.net, taxBp: p.tax_bp });
|
||||
if (items.length === 0) items.push({ description: p.name, quantity: 1, unitPriceNetCents: 0, taxBp: p.tax_bp });
|
||||
if (price.recurring.net > 0) items.push({ description: label, quantity: 1, unitPriceNetCents: price.recurring.net, taxBp: p.tax_bp });
|
||||
if (items.length === 0) items.push({ description: label, quantity: 1, unitPriceNetCents: 0, taxBp: p.tax_bp });
|
||||
return { items };
|
||||
});
|
||||
/** Vorschlag aus der Domain-Aufstellung: der dort errechnete Verkaufspreis (nie der Einkauf), plus Einrichtung falls vorhanden. */
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export const ordersModule: KcModule = {
|
|||
// ---- Bestellung anlegen ------------------------------------------------
|
||||
app.post('/orders', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0) })).min(1).max(20) }).parse(req.body);
|
||||
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0), termMonths: z.number().int().min(1).max(120).optional() })).min(1).max(20) }).parse(req.body);
|
||||
const staff = can(a.principal, 'orders.write');
|
||||
if (!canInOrg(a.principal, b.orgId, 'orders.create', 'orders.write')) { if (!canInOrg(a.principal, b.orgId, 'orders.read', 'orders.read')) throw notFound(); throw forbidden(); }
|
||||
const org = await one('SELECT id, status, customer_type FROM organizations WHERE id = ?', [b.orgId]);
|
||||
|
|
@ -74,9 +74,17 @@ export const ordersModule: KcModule = {
|
|||
if (!staff && !p.orderable_by_customer) throw badRequest('Dieses Produkt kann nicht selbst bestellt werden', 'NOT_ORDERABLE');
|
||||
if (!staff && it.discountBp > 0) throw forbidden('Rabatte können nur vom Personal gewährt werden', 'DISCOUNT_FORBIDDEN');
|
||||
if (p.connector_instance_id && it.quantity !== 1) throw badRequest('Provisionierte Produkte können nur einzeln bestellt werden', 'QUANTITY_NOT_ALLOWED');
|
||||
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht
|
||||
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
|
||||
let terms = { termMonths: p.term_months as number, renewal: p.renewal as 'auto' | 'none', renewalTermMonths: p.renewal_term_months as number, noticeDays: p.notice_days as number };
|
||||
// Staffelpreis nach Laufzeit (optional): ersetzt den wiederkehrenden Basispreis, wenn eine passende Laufzeit gewählt wurde
|
||||
// (das Abrechnungsintervall bleibt für die Anzeige erhalten, z. B. "jährlich"; die tatsächliche Laufzeit steht separat in terms.termMonths).
|
||||
let recurringCents = p.recurring_cents, termMonths = p.term_months as number, renewalTermMonths = p.renewal_term_months as number;
|
||||
if (it.termMonths !== undefined) {
|
||||
const tier = await one('SELECT recurring_cents FROM product_term_prices WHERE product_version_id = ? AND term_months = ?', [p.vid, it.termMonths]);
|
||||
if (!tier) throw badRequest('Diese Laufzeit wird für dieses Produkt nicht angeboten', 'TERM_NOT_AVAILABLE');
|
||||
recurringCents = tier.recurring_cents; termMonths = it.termMonths; if (p.renewal === 'auto') renewalTermMonths = it.termMonths;
|
||||
}
|
||||
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht (außer der Laufzeitwahl oben)
|
||||
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
|
||||
let terms = { termMonths, renewal: p.renewal as 'auto' | 'none', renewalTermMonths, noticeDays: p.notice_days as number };
|
||||
if (org.customer_type === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) }; // Verbraucherregel, rechtlich zu prüfen
|
||||
lines.push({ p, it, snapshot: { productId: p.id, sku: p.sku, productVersionId: p.vid, version: p.version, name: p.vname, category: p.category, customerType: org.customer_type, ...price, terms } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,3 +113,20 @@ describe('Rechnungspositionen aus der Domain-Aufstellung', () => {
|
|||
expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().totalNetCents).toBe(1891);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rechnungsposition aus Produkt mit Laufzeit-Staffel', () => {
|
||||
it('Ohne Laufzeitangabe der Basispreis, mit Laufzeitangabe die Staffel; unbekannte Laufzeit abgelehnt', async () => {
|
||||
const admin = await staff('psugg-admin@example.com', 'superadmin'); const acc = await staff('psugg-acc@example.com', 'accounting');
|
||||
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
|
||||
const prod = await call(app, admin, 'POST', '/admin/products', {
|
||||
sku: 'HOST-PSUGG', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active',
|
||||
version: { name: 'Hosting X', taxRateId: t19, setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 1, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {},
|
||||
termPrices: [{ termMonths: 12, recurringCents: 9588 }] },
|
||||
});
|
||||
const base = (await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}`)).json();
|
||||
expect(base.items).toEqual([{ description: 'Hosting X', quantity: 1, unitPriceNetCents: 999, taxBp: 1900 }]);
|
||||
const tier = (await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}?termMonths=12`)).json();
|
||||
expect(tier.items).toEqual([{ description: 'Hosting X (12 Monate)', quantity: 1, unitPriceNetCents: 9588, taxBp: 1900 }]);
|
||||
expect((await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}?termMonths=6`)).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -266,3 +266,37 @@ describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => {
|
|||
const days = (new Date(tr.validUntil).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(13); expect(days).toBeLessThan(14.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Staffelpreise nach Laufzeit', () => {
|
||||
it('Produkt mit mehreren Laufzeiten: Bestellung wählt eine Staffel, Preis/Laufzeit werden serverseitig aus der Staffel übernommen', async () => {
|
||||
const admin = await staff('term-admin@example.com', 'admin');
|
||||
const A = await customer(admin, 'business', 'Firma Term', 'term-a@example.com');
|
||||
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
|
||||
const prod = await call(app, admin, 'POST', '/admin/products', {
|
||||
sku: 'HOST-TERM', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active',
|
||||
version: { name: 'Hosting Staffel', taxRateId: t19, setupCents: 500, recurringCents: 999, billingInterval: 'monthly', termMonths: 1, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {},
|
||||
termPrices: [{ termMonths: 1, recurringCents: 999 }, { termMonths: 3, recurringCents: 2699 }, { termMonths: 12, recurringCents: 9588 }, { termMonths: 24, recurringCents: 17988 }] },
|
||||
});
|
||||
expect(prod.statusCode).toBe(200);
|
||||
// doppelte Laufzeit wird abgelehnt
|
||||
expect((await call(app, admin, 'POST', '/admin/products', {
|
||||
sku: 'HOST-TERM-BAD', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'draft',
|
||||
version: { name: 'x', taxRateId: t19, setupCents: 0, recurringCents: 0, billingInterval: 'monthly', termMonths: 1, renewal: 'none', noticeDays: 30, provisioning: {}, termPrices: [{ termMonths: 12, recurringCents: 100 }, { termMonths: 12, recurringCents: 200 }] },
|
||||
})).statusCode).toBe(400);
|
||||
|
||||
const cat = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json();
|
||||
const item = cat.find((c: any) => c.sku === 'HOST-TERM');
|
||||
expect(item.termPrices).toEqual([
|
||||
{ termMonths: 1, price: { net: 999, tax: 190, gross: 1189 } }, { termMonths: 3, price: { net: 2699, tax: 513, gross: 3212 } },
|
||||
{ termMonths: 12, price: { net: 9588, tax: 1822, gross: 11410 } }, { termMonths: 24, price: { net: 17988, tax: 3418, gross: 21406 } },
|
||||
]);
|
||||
|
||||
// nicht angebotene Laufzeit wird abgelehnt
|
||||
expect((await call(app, A.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.json().id, termMonths: 6 }] })).statusCode).toBe(400);
|
||||
|
||||
const order = (await call(app, A.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.json().id, termMonths: 12 }] })).json();
|
||||
const items = (await call(app, admin, 'GET', `/orders/${order.id}`)).json().items;
|
||||
expect(items[0].snapshot.recurring).toEqual({ net: 9588, tax: 1822, gross: 11410 });
|
||||
expect(items[0].snapshot.terms).toEqual({ termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
|
|||
import '../src/core/config.js';
|
||||
|
||||
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
|
||||
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'invoice_items', 'invoices', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
|
||||
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'invoice_items', 'invoices', 'product_term_prices', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
|
||||
beforeAll(async () => {
|
||||
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
|
||||
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useSession } from '@/lib/session';
|
|||
import { Alert, eur, Field, fmt, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
||||
import type { Prod } from '../page';
|
||||
|
||||
const TERMS = [1, 3, 6, 12, 24];
|
||||
type D = Prod & { versions: NonNullable<Prod['current']>[] };
|
||||
export default function Produkt() {
|
||||
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const w = can('products.write');
|
||||
|
|
@ -18,8 +19,9 @@ export default function Produkt() {
|
|||
async function patch(b: object, ok: string) { try { await api('PATCH', `/admin/products/${id}`, b); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||
async function newVersion(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); const f = new FormData(e.currentTarget); const once = cur.billingInterval === 'once';
|
||||
const termPrices = once ? [] : TERMS.map((m) => ({ termMonths: m, recurringCents: f.get(`term${m}`) === '' || f.get(`term${m}`) === null ? null : Math.round(Number(f.get(`term${m}`)) * 100) })).filter((t): t is { termMonths: number; recurringCents: number } => t.recurringCents !== null);
|
||||
try { await api('POST', `/admin/products/${id}/versions`, { name: f.get('name'), taxRateId: f.get('tax'), priceBasis: cur.priceBasis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(f.get('recurring') || 0) * 100), billingInterval: cur.billingInterval,
|
||||
termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning });
|
||||
termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning, termPrices });
|
||||
setOpen(false); setMsg({ k: 'ok', t: 'Neue Version angelegt. Bestehende Verträge behalten ihren Preis.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||
}
|
||||
return (<>
|
||||
|
|
@ -29,6 +31,7 @@ export default function Produkt() {
|
|||
<div className="card"><h2>Aktuelle Konditionen (Version {cur.version})</h2>
|
||||
<p>{cur.setupCents > 0 && <>Einrichtung {eur(cur.setupCents)} {cur.priceBasis === 'gross' ? 'brutto' : 'netto'}<br /></>}{cur.billingInterval === 'once' ? 'Einmalpreis' : `${INTERVAL[cur.billingInterval]} ${eur(cur.recurringCents)} ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}`} · Steuer {cur.taxBp / 100} %</p>
|
||||
<p className="small muted">{cur.termMonths > 0 ? `Mindestlaufzeit ${cur.termMonths} Monate` : 'Keine Mindestlaufzeit'}{cur.renewal === 'auto' ? `, Verlängerung um ${cur.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {cur.noticeDays} Tage · Bereitstellung: {d.connectorName ?? 'manuell'}{Object.keys(d.provisioning).length ? ` (${JSON.stringify(d.provisioning)})` : ''}</p>
|
||||
{d.termPrices.length > 0 && <p className="small muted">Laufzeit-Staffelpreise: {d.termPrices.map((t) => `${t.termMonths} Mon. ${eur(t.recurringCents)}`).join(' · ')}</p>}
|
||||
{w && <div className="row">{d.status !== 'active' && <button className="btn primary" onClick={() => patch({ status: 'active' }, 'Produkt aktiviert.')}>Aktivieren</button>}
|
||||
{d.status === 'active' && <button className="btn" onClick={() => patch({ status: 'retired' }, 'Produkt ausgelaufen. Es kann nicht mehr bestellt werden.')}>Auslaufen lassen</button>}
|
||||
<button className="btn" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Version (Preis ändern)</button></div>}</div>
|
||||
|
|
@ -38,6 +41,11 @@ export default function Produkt() {
|
|||
{cur.billingInterval !== 'once' && <Field id="recurring" label={`Preis (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" defaultValue={cur.recurringCents / 100} /></Field>}
|
||||
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === cur.taxBp)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
||||
<Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={cur.noticeDays} /></Field></div>
|
||||
{cur.billingInterval !== 'once' && <>
|
||||
<h3>Staffelpreise nach Laufzeit (optional)</h3>
|
||||
<p className="small muted">Preis jeweils für die gesamte Laufzeit, nicht pro Monat. Leer lassen, wenn diese Laufzeit nicht angeboten wird.</p>
|
||||
<div className="cols">{TERMS.map((m) => <Field key={m} id={`term${m}`} label={`${m} Monat${m > 1 ? 'e' : ''} (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}, gesamt)`}><input id={`term${m}`} name={`term${m}`} type="number" min={0} step="0.01" defaultValue={d.termPrices.find((t) => t.termMonths === m) ? d.termPrices.find((t) => t.termMonths === m)!.recurringCents / 100 : ''} /></Field>)}</div>
|
||||
</>}
|
||||
<div className="row"><button className="btn primary" type="submit">Version speichern</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
|
||||
<div className="card"><h2>Verkaufsregeln</h2><div className="row">
|
||||
<label className="row"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.orderableByCustomer} onChange={(e) => patch({ orderableByCustomer: e.target.checked }, 'Gespeichert.')} />Von Kunden selbst bestellbar</label>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Alert, Field, ACTION_LABEL, CUSTOMER_ACTIONS, INTERVAL } from '@/compon
|
|||
interface Conn { id: string; name: string; connector: string; capabilities: string[] }
|
||||
const LIMITS: [string, string, string][] = [['diskSpaceGb', 'Speicher (GB)', '10'], ['trafficGb', 'Traffic pro Monat (GB)', '100'], ['domains', 'Domains', '3'], ['subdomains', 'Subdomains', '10'], ['emailAccounts', 'Postfächer', '10'], ['emailAddresses', 'E-Mail-Adressen', '20'], ['emailForwardings', 'Weiterleitungen', '10'], ['databases', 'Datenbanken', '3'], ['ftpUsers', 'FTP-Benutzer', '3'], ['scheduledTasks', 'Cronjobs', '2']];
|
||||
const PERMS: [string, string][] = [['panel_access', 'Zugang zum Kundenpanel'], ['ftp', 'FTP'], ['php', 'PHP'], ['ssh', 'SSH'], ['backup', 'Backups'], ['file_manager', 'Dateimanager'], ['dns_editor', 'DNS-Editor'], ['certificate_management', 'SSL-Zertifikate verwalten'], ['domain_email', 'E-Mail-Domains'], ['email_catchall', 'Catch-all'], ['database_remote_access', 'Datenbank-Fernzugriff'], ['delete_main_domain', 'Hauptdomain löschen']];
|
||||
const TERMS = [1, 3, 6, 12, 24];
|
||||
|
||||
export default function HostingTarif() {
|
||||
const { can } = useSession();
|
||||
|
|
@ -21,11 +22,12 @@ export default function HostingTarif() {
|
|||
const limits = Object.fromEntries(LIMITS.map(([k]) => [k, unl[k] ? null : (g(k) === '' ? undefined : Number(g(k)))]).filter(([, v]) => v !== undefined));
|
||||
const permissions = Object.fromEntries(PERMS.map(([k]) => [k, f.get(`perm-${k}`) === 'on']));
|
||||
const once = interval === 'once';
|
||||
const termPrices = once ? [] : TERMS.map((m) => ({ termMonths: m, recurringCents: g(`term${m}`) === '' ? null : Math.round(Number(g(`term${m}`)) * 100) })).filter((t): t is { termMonths: number; recurringCents: number } => t.recurringCents !== null);
|
||||
try {
|
||||
const r = await api<{ productId: string; plan: { name: string; features: string[] } }>('POST', `/admin/connectors/${connId}/hosting-plans`, {
|
||||
plan: { name: g('planName'), limits, permissions },
|
||||
product: { sku: g('sku'), status: f.get('activate') === 'on' ? 'active' : 'draft', orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', customerActions: CUSTOMER_ACTIONS.filter((a) => f.get(`act-${a}`) === 'on'),
|
||||
version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30) } } });
|
||||
version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30), termPrices } } });
|
||||
setOk({ id: r.productId, name: r.plan.name, features: r.plan.features });
|
||||
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
|
|
@ -54,6 +56,11 @@ export default function HostingTarif() {
|
|||
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === 1900)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
||||
{interval !== 'once' && <><Field id="term" label="Mindestlaufzeit (Monate)"><input id="term" name="term" type="number" min={0} defaultValue={0} /></Field><Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={30} /></Field></>}</div>
|
||||
{interval !== 'once' && <label className="row"><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label>}
|
||||
{interval !== 'once' && <>
|
||||
<h3>Staffelpreise nach Laufzeit (optional)</h3>
|
||||
<p className="small muted">Lässt Kunden bei der Bestellung eine Laufzeit wählen, z. B. günstiger bei 12 oder 24 Monaten. Preis jeweils für die gesamte Laufzeit, nicht pro Monat. Leer lassen, wenn diese Laufzeit nicht angeboten wird.</p>
|
||||
<div className="cols">{TERMS.map((m) => <Field key={m} id={`term${m}`} label={`${m} Monat${m > 1 ? 'e' : ''} (€ ${basis === 'gross' ? 'brutto' : 'netto'}, gesamt)`}><input id={`term${m}`} name={`term${m}`} type="number" min={0} step="0.01" /></Field>)}</div>
|
||||
</>}
|
||||
<h3>Verkauf</h3>
|
||||
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
|
||||
<fieldset style={{ border: 0, padding: 0, margin: '12px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Was Kunden am Hosting-Konto selbst tun dürfen</legend><div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} defaultChecked={['panel.login', 'domain.manage', 'email.manage'].includes(a)} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { useSession } from '@/lib/session';
|
|||
import { Alert, Empty, eur, Field, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
||||
|
||||
export interface Prod { id: string; sku: string; category: string; status: string; connectorInstanceId: string | null; connectorName: string | null; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; provisioning: Record<string, unknown>;
|
||||
current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null }
|
||||
current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null;
|
||||
termPrices: { termMonths: number; recurringCents: number }[] }
|
||||
export const CAT: Record<string, string> = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' };
|
||||
const PSTATUS: Record<string, string> = { active: 'active', draft: 'invited', retired: 'closed' };
|
||||
export default function Produkte() {
|
||||
|
|
|
|||
|
|
@ -3,19 +3,20 @@ import { useEffect, useState, type FormEvent } from 'react';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, type Price, PriceText } from '@/components/ui';
|
||||
import { Alert, Empty, eur, Field, type Price, PriceText } from '@/components/ui';
|
||||
|
||||
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number }
|
||||
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; termPrices: { termMonths: number; price: { net: number; tax: number; gross: number } }[] }
|
||||
interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' }
|
||||
export default function Bestellen() {
|
||||
const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write');
|
||||
const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState<P[] | null>(null); const [sel, setSel] = useState<P | null>(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
|
||||
const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState<P[] | null>(null); const [sel, setSel] = useState<P | null>(null); const [term, setTerm] = useState<number | ''>(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
|
||||
useEffect(() => { if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); else if (me?.organizations[0]) setOrg(me.organizations[0].id); }, [staff, me]);
|
||||
useEffect(() => { if (org) api<P[]>('GET', `/catalog?org=${org}`).then(setCat).catch((e) => setErr(errMsg(e))); }, [org]);
|
||||
const priv = (cat?.[0] as (P & { customerType?: string }) | undefined)?.customerType === 'private';
|
||||
const selPrice: Price | null = sel && (term === '' ? sel.price : { ...sel.price, recurring: sel.termPrices.find((t) => t.termMonths === term)!.price });
|
||||
async function order(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); if (!sel) return; setBusy(true); setErr(''); const f = new FormData(e.currentTarget);
|
||||
try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); }
|
||||
try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(term !== '' ? { termMonths: term } : {}), ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); }
|
||||
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
return (<>
|
||||
|
|
@ -25,8 +26,15 @@ export default function Bestellen() {
|
|||
<div className="grid">{cat.map((p) => <div className="card" key={p.id} style={sel?.id === p.id ? { borderColor: 'var(--accent)' } : undefined}><h2>{p.name}</h2>{p.description && <p className="muted">{p.description}</p>}
|
||||
<div className="small"><PriceText p={p.price} priv={priv} /></div>
|
||||
<p className="small muted">{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage</p>
|
||||
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => setSel(p)}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>
|
||||
{sel && <form className="card" onSubmit={order}><h2>Bestellung prüfen</h2><p><strong>{sel.name}</strong></p><div className="small" style={{ marginBottom: 16 }}><PriceText p={sel.price} priv={priv} /></div>
|
||||
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => { setSel(p); setTerm(''); }}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>
|
||||
{sel && selPrice && <form className="card" onSubmit={order}><h2>Bestellung prüfen</h2><p><strong>{sel.name}</strong></p>
|
||||
{sel.termPrices.length > 0 && <Field id="term" label="Laufzeit" hint="Bei längerer Laufzeit oft günstiger."><select id="term" value={term} onChange={(e) => setTerm(e.target.value === '' ? '' : Number(e.target.value))}><option value="">Standard{sel.termMonths > 0 ? ` (${sel.termMonths} Monate)` : ''}</option>{sel.termPrices.map((t) => <option key={t.termMonths} value={t.termMonths}>{t.termMonths} Monate</option>)}</select></Field>}
|
||||
<div className="small" style={{ marginBottom: 16 }}>
|
||||
{term === '' ? <PriceText p={selPrice} priv={priv} /> : (<>
|
||||
{selPrice.setup.net > 0 && <div>Einrichtung: {priv ? `${eur(selPrice.setup.gross)} inkl. USt.` : `${eur(selPrice.setup.net)} netto (${eur(selPrice.setup.gross)} brutto)`}</div>}
|
||||
<div>Laufzeit {term} Monate, gesamt: {priv ? `${eur(selPrice.recurring.gross)} inkl. USt.` : `${eur(selPrice.recurring.net)} netto (${eur(selPrice.recurring.gross)} brutto)`}</div>
|
||||
</>)}
|
||||
</div>
|
||||
{staff && <Field id="discount" label="Rabatt in %" hint="Nur Personal. Der Preis wird serverseitig berechnet."><input id="discount" name="discount" type="number" min={0} max={100} step="0.01" defaultValue={0} /></Field>}
|
||||
<Field id="note" label="Anmerkung (optional)"><input id="note" name="note" maxLength={500} /></Field>
|
||||
<p className="small muted">{staff ? 'Die Bestellung wird sofort freigegeben und bereitgestellt.' : sel.requiresApproval ? 'Ihre Bestellung wird von uns geprüft und freigegeben. Sie sehen den Stand unter „Bestellungen“.' : 'Die Bereitstellung startet sofort.'}</p>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ type Row = { description: string; quantity: string; unitPrice: string; taxBp: st
|
|||
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' });
|
||||
interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number }
|
||||
const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) });
|
||||
interface Prod { id: string; status: string; current: { name: string } | null }
|
||||
interface Prod { id: string; status: string; current: { name: string } | null; termPrices: { termMonths: number }[] }
|
||||
interface Contract { id: string; number: string; productName: string | null }
|
||||
interface Dom { id: string; domain: string; sellNetCents: number | null }
|
||||
|
||||
|
|
@ -32,9 +32,9 @@ export default function RechnungDetail() {
|
|||
|
||||
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } };
|
||||
function appendSuggested(items: Suggested[]) { setRows((rs) => [...rs.filter((r) => r.description.trim() || r.unitPrice.trim()), ...items.map(toRow)]); }
|
||||
async function takeFrom(kind: 'product' | 'contract' | 'domain', pickId: string) {
|
||||
async function takeFrom(kind: 'product' | 'contract' | 'domain', pickId: string, termMonths?: string) {
|
||||
if (!pickId) return; setErr('');
|
||||
try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); }
|
||||
try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}${termMonths ? `?termMonths=${termMonths}` : ''}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
async function saveItems(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
|
@ -55,7 +55,11 @@ export default function RechnungDetail() {
|
|||
|
||||
{isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2>
|
||||
{(products.length > 0 || contracts.length > 0 || domains.length > 0) && <div className="cols" style={{ marginBottom: 16 }}>
|
||||
{products.length > 0 && <Field id="fromProduct" label="Position aus Produkt übernehmen" hint="Fügt Einrichtung und/oder wiederkehrenden Preis als eigene Zeilen hinzu."><select id="fromProduct" value="" onChange={(e) => void takeFrom('product', e.target.value)}><option value="">Produkt wählen …</option>{products.map((p) => <option key={p.id} value={p.id}>{p.current!.name}</option>)}</select></Field>}
|
||||
{products.length > 0 && <Field id="fromProduct" label="Position aus Produkt übernehmen" hint="Fügt Einrichtung und/oder wiederkehrenden Preis als eigene Zeilen hinzu. Bei Laufzeit-Staffelpreisen die gewünschte Laufzeit wählen."><select id="fromProduct" value="" onChange={(e) => { const [pid, tm] = e.target.value.split('|'); void takeFrom('product', pid!, tm); }}><option value="">Produkt wählen …</option>
|
||||
{products.map((p) => p.termPrices.length > 0
|
||||
? <optgroup key={p.id} label={p.current!.name}>{p.termPrices.map((t) => <option key={t.termMonths} value={`${p.id}|${t.termMonths}`}>{t.termMonths} Monate</option>)}</optgroup>
|
||||
: <option key={p.id} value={p.id}>{p.current!.name}</option>)}
|
||||
</select></Field>}
|
||||
{contracts.length > 0 && <Field id="fromContract" label="Position aus laufendem Vertrag übernehmen" hint="Übernimmt den wiederkehrenden Preis der aktuellen Periode."><select id="fromContract" value="" onChange={(e) => void takeFrom('contract', e.target.value)}><option value="">Vertrag wählen …</option>{contracts.map((c) => <option key={c.id} value={c.id}>{c.number} · {c.productName ?? '–'}</option>)}</select></Field>}
|
||||
{domains.length > 0 && <Field id="fromDomain" label="Position aus Domain übernehmen" hint="Verkaufspreis aus der Domain-Aufstellung, nie der Einkauf."><select id="fromDomain" value="" onChange={(e) => void takeFrom('domain', e.target.value)}><option value="">Domain wählen …</option>{domains.map((d) => <option key={d.id} value={d.id} disabled={d.sellNetCents === null}>{d.domain}{d.sellNetCents === null ? ' (kein Verkaufspreis)' : ''}</option>)}</select></Field>}
|
||||
</div>}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue