Compare commits

..
Author SHA1 Message Date
gitea_admin c11cb39dc4 Sync styleguide 2026.05.18.1 2026-09-06 15:32:07 +02:00
95 changed files with 946 additions and 10976 deletions

No files matched your search

-7
View File
@@ -53,10 +53,3 @@ Diese Datei enthaelt operative Arbeitsregeln fuer Agenten in diesem Repository.
- Wenn Prozesse umgesetzt werden: verwende immer den Skill prozess-umsetzung
- Wenn UI umgesetzt werden soll: verwende immer den Skill styleguide-anwendung
- Zwingende Vorgabe für alle Arbeiten ist /docs/technical_architecture.md
## 9. MCP-Dokumentation
- Bei jeder Änderung an der MCP-Schnittstelle des Marktmanagements ist neben der
Repository-Dokumentation auch die MCP-Dokumentation unter
`/Volumes/Hermes/Hermes Naurua/Marktmanagement/MCP` zu aktualisieren.
- Die MCP-Dokumentation ist damit ein verbindlicher zusätzlicher Dokumentationsstand
für alle zukünftigen MCP-Änderungen in diesem Projekt.
-7
View File
@@ -15,13 +15,6 @@
11. `db/migrations/0015_sales_order_payments.sql`
12. `db/migrations/0016_stock_lot_forecast_history.sql`
13. `db/migrations/0017_manufacturer_lot_traceability.sql`
14. `db/migrations/0018_shopify_delta_sync_state.sql`
15. `db/migrations/0019_allow_multiple_open_stock_lots.sql`
16. `db/migrations/0020_include_stock_adjustments_in_balance.sql`
17. `db/migrations/0021_stock_lot_activation_selection.sql`
18. `db/migrations/0022_backfill_stock_lot_activation_selection.sql`
19. `db/migrations/0023_market_management.sql` bis `db/migrations/0034_market_occurrence_checklist.sql`
20. `db/migrations/0035_market_occurrence_checklist_chapters.sql`
## Beispielausfuehrung
@@ -1,5 +0,0 @@
BEGIN;
DROP INDEX IF EXISTS uq_stock_lot_one_open_per_product;
COMMIT;
@@ -1,22 +0,0 @@
BEGIN;
CREATE OR REPLACE VIEW v_stock_lot_balance AS
SELECT
sl.id AS stock_lot_id,
sl.product_id,
COALESCE(SUM(CASE WHEN sm.move_type = 'in' THEN sm.qty ELSE 0 END), 0) AS qty_in,
COALESCE(SUM(CASE WHEN sm.move_type = 'out' THEN sm.qty ELSE 0 END), 0) AS qty_out,
COALESCE(SUM(
CASE
WHEN sm.move_type = 'in' THEN sm.qty
WHEN sm.move_type = 'out' THEN -sm.qty
WHEN sm.move_type = 'adjustment' AND sm.to_location_id IS NOT NULL AND sm.from_location_id IS NULL THEN sm.qty
WHEN sm.move_type = 'adjustment' AND sm.from_location_id IS NOT NULL AND sm.to_location_id IS NULL THEN -sm.qty
ELSE 0
END
), 0) AS qty_net
FROM stock_lot sl
LEFT JOIN stock_move sm ON sm.lot_id = sl.id
GROUP BY sl.id, sl.product_id;
COMMIT;
@@ -1,119 +0,0 @@
BEGIN;
ALTER TABLE stock_lot
ADD COLUMN IF NOT EXISTS next_active BOOLEAN NOT NULL DEFAULT FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_stock_lot_one_next_active_per_product
ON stock_lot(product_id)
WHERE status = 'open' AND next_active = TRUE;
CREATE OR REPLACE FUNCTION fn_auto_switch_lot_when_depleted()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_product_id BIGINT;
v_open_lot_id BIGINT;
v_qualified_count INTEGER;
v_current_net NUMERIC;
v_event_key TEXT;
BEGIN
SELECT product_id
INTO v_product_id
FROM stock_lot
WHERE id = NEW.lot_id;
IF NOT EXISTS (
SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current'
) THEN
RETURN NEW;
END IF;
SELECT qty_net
INTO v_current_net
FROM v_stock_lot_balance
WHERE stock_lot_id = NEW.lot_id;
IF COALESCE(v_current_net, 0) > 0 THEN
RETURN NEW;
END IF;
PERFORM 1
FROM stock_lot
WHERE product_id = v_product_id
FOR UPDATE;
IF NOT EXISTS (
SELECT 1 FROM stock_lot WHERE id = NEW.lot_id AND status = 'current'
) THEN
RETURN NEW;
END IF;
SELECT sl.id
INTO v_open_lot_id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = v_product_id
AND sl.status = 'open'
AND sl.next_active = TRUE
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
LIMIT 1;
IF v_open_lot_id IS NULL THEN
SELECT COUNT(*), MIN(sl.id)
INTO v_qualified_count, v_open_lot_id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = v_product_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0;
IF v_qualified_count <> 1 THEN
RETURN NEW;
END IF;
UPDATE stock_lot
SET next_active = TRUE, updated_at = NOW()
WHERE id = v_open_lot_id;
END IF;
UPDATE stock_lot
SET status = 'closed', updated_at = NOW()
WHERE id = NEW.lot_id;
UPDATE stock_lot
SET status = 'current', next_active = FALSE, updated_at = NOW()
WHERE id = v_open_lot_id;
v_event_key := format('lot.auto_switched:%s:%s', v_product_id, txid_current());
PERFORM fn_enqueue_event(
'lot.auto_switched',
v_event_key,
'stock_lot',
NEW.lot_id::TEXT,
jsonb_build_object(
'productId', v_product_id,
'closedLotId', NEW.lot_id,
'newCurrentLotId', v_open_lot_id,
'occurredAt', NOW()
)
);
RETURN NEW;
END;
$$;
COMMIT;
@@ -1,25 +0,0 @@
BEGIN;
WITH qualified AS (
SELECT
sl.id,
sl.product_id,
COUNT(*) OVER (PARTITION BY sl.product_id) AS qualified_count
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
)
UPDATE stock_lot sl
SET next_active = TRUE, updated_at = NOW()
FROM qualified q
WHERE sl.id = q.id
AND q.qualified_count = 1;
COMMIT;
-215
View File
@@ -1,215 +0,0 @@
BEGIN;
-- Market Management base schema.
-- Fachliche Ownership: market_management.
CREATE TABLE market_category (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_category_code ON market_category(code);
CREATE UNIQUE INDEX uq_market_category_name ON market_category(name);
INSERT INTO market_category (code, name)
VALUES
('herbstmarkt', 'Herbstmärkte'),
('weihnachtsmarkt', 'Weihnachtsmärkte'),
('fruehlingsmarkt', 'Frühlingsmärkte'),
('ostermarkt', 'Ostermärkte'),
('kunsthandwerksmarkt', 'Kunsthandwerksmärkte'),
('regionalmarkt', 'Regionalmärkte'),
('weitere_geeignete_maerkte', 'Weitere geeignete Märkte');
CREATE TABLE market_municipality_reference (
id BIGSERIAL PRIMARY KEY,
bfs_municipality_number INTEGER NOT NULL,
name TEXT NOT NULL,
canton_code TEXT NOT NULL,
data_version DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_municipality_reference_bfs_number_version
ON market_municipality_reference(bfs_municipality_number, data_version);
CREATE INDEX idx_market_municipality_reference_canton
ON market_municipality_reference(canton_code, name);
CREATE TABLE market_definition (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
category_id BIGINT NOT NULL REFERENCES market_category(id) ON DELETE RESTRICT,
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_definition_name ON market_definition(name);
CREATE INDEX idx_market_definition_category ON market_definition(category_id);
CREATE TABLE market_occurrence (
id BIGSERIAL PRIMARY KEY,
market_definition_id BIGINT NOT NULL REFERENCES market_definition(id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'open',
place TEXT,
address TEXT,
municipality_reference_id BIGINT REFERENCES market_municipality_reference(id) ON DELETE RESTRICT,
notes TEXT,
registration_deadline DATE,
request_or_registration_date DATE,
confirmation_date DATE,
payment_deadline DATE,
stand_size TEXT,
table_available BOOLEAN,
table_rentable BOOLEAN,
own_table_required BOOLEAN,
shelter_available BOOLEAN,
power_available BOOLEAN,
water_available BOOLEAN,
lighting_available BOOLEAN,
other_infrastructure TEXT,
own_stand_requirements TEXT,
travel_address TEXT,
travel_distance_km NUMERIC(10, 2),
travel_duration_minutes INTEGER,
parking_options TEXT,
delivery_access TEXT,
direct_unload_possible BOOLEAN,
storage_options TEXT,
travel_notes TEXT,
payment_status TEXT NOT NULL DEFAULT 'unpaid',
payment_date DATE,
paid_amount NUMERIC(14, 2),
payment_method_code TEXT,
cancellation_terms TEXT,
revenue_share_terms TEXT,
other_participation_conditions TEXT,
contract_folder_url TEXT,
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_status CHECK (
status IN ('open', 'waitlist', 'registered', 'confirmation_pending', 'confirmed', 'cancelled', 'completed')
),
CONSTRAINT chk_market_occurrence_payment_status CHECK (
payment_status IN ('paid', 'unpaid')
),
CONSTRAINT chk_market_occurrence_travel_distance CHECK (
travel_distance_km IS NULL OR travel_distance_km >= 0
),
CONSTRAINT chk_market_occurrence_travel_duration CHECK (
travel_duration_minutes IS NULL OR travel_duration_minutes >= 0
),
CONSTRAINT chk_market_occurrence_paid_amount CHECK (
paid_amount IS NULL OR paid_amount >= 0
)
);
CREATE INDEX idx_market_occurrence_market ON market_occurrence(market_definition_id);
CREATE INDEX idx_market_occurrence_status ON market_occurrence(status);
CREATE INDEX idx_market_occurrence_municipality
ON market_occurrence(municipality_reference_id);
CREATE INDEX idx_market_occurrence_archived_at ON market_occurrence(archived_at);
CREATE TABLE market_occurrence_organizer (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL UNIQUE REFERENCES market_occurrence(id) ON DELETE RESTRICT,
organization_contact_id BIGINT NOT NULL,
contact_person_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_market_occurrence_organizer_organization
ON market_occurrence_organizer(organization_contact_id);
CREATE INDEX idx_market_occurrence_organizer_contact_person
ON market_occurrence_organizer(contact_person_id);
CREATE TABLE market_occurrence_day (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
date DATE NOT NULL,
stand_start_time TIME,
stand_end_time TIME,
setup_start_time TIME,
setup_end_time TIME,
delivery_start_time TIME,
delivery_end_time TIME,
observations TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_day_stand_time CHECK (
stand_start_time IS NULL OR stand_end_time IS NULL OR stand_end_time >= stand_start_time
),
CONSTRAINT chk_market_occurrence_day_setup_time CHECK (
setup_start_time IS NULL OR setup_end_time IS NULL OR setup_end_time >= setup_start_time
),
CONSTRAINT chk_market_occurrence_day_delivery_time CHECK (
delivery_start_time IS NULL OR delivery_end_time IS NULL OR delivery_end_time >= delivery_start_time
)
);
CREATE UNIQUE INDEX uq_market_occurrence_day_date ON market_occurrence_day(occurrence_id, date);
CREATE INDEX idx_market_occurrence_day_date ON market_occurrence_day(date);
CREATE TABLE market_occurrence_cost_item (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
cost_type TEXT NOT NULL,
description TEXT,
gross_amount NUMERIC(14, 2) NOT NULL,
tax_status TEXT NOT NULL,
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
tax_amount NUMERIC(14, 2) GENERATED ALWAYS AS (
CASE
WHEN tax_status = 'tax_free' THEN 0
ELSE ROUND(gross_amount - gross_amount / (1 + tax_rate / 100), 2)
END
) STORED,
net_amount NUMERIC(14, 2) GENERATED ALWAYS AS (
gross_amount - CASE
WHEN tax_status = 'tax_free' THEN 0
ELSE ROUND(gross_amount - gross_amount / (1 + tax_rate / 100), 2)
END
) STORED,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_cost_gross_nonnegative CHECK (gross_amount >= 0),
CONSTRAINT chk_market_occurrence_cost_tax_status CHECK (tax_status IN ('tax_free', 'taxable')),
CONSTRAINT chk_market_occurrence_cost_tax_rate CHECK (
(tax_status = 'tax_free' AND tax_rate = 0)
OR (tax_status = 'taxable' AND tax_rate > 0)
)
);
CREATE INDEX idx_market_occurrence_cost_item_occurrence
ON market_occurrence_cost_item(occurrence_id);
CREATE TABLE market_mcp_idempotency (
id BIGSERIAL PRIMARY KEY,
user_subject TEXT NOT NULL,
client_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
tool_name TEXT NOT NULL,
request_hash TEXT NOT NULL,
result_status TEXT NOT NULL,
result_entity_type TEXT,
result_entity_id TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
CONSTRAINT chk_market_mcp_idempotency_result_status CHECK (
result_status IN ('processing', 'succeeded', 'failed')
)
);
CREATE UNIQUE INDEX uq_market_mcp_idempotency_client_key
ON market_mcp_idempotency(client_id, idempotency_key);
CREATE INDEX idx_market_mcp_idempotency_expiry ON market_mcp_idempotency(expires_at);
COMMIT;
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE market_mcp_idempotency
ADD COLUMN result_payload JSONB;
COMMIT;
@@ -1,10 +0,0 @@
BEGIN;
ALTER TABLE market_definition
ADD COLUMN website_url TEXT;
ALTER TABLE market_definition
ADD CONSTRAINT chk_market_definition_website_url
CHECK (website_url IS NULL OR website_url ~* '^https?://');
COMMIT;
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE market_occurrence_day
ADD COLUMN inherit_from_first_day BOOLEAN NOT NULL DEFAULT FALSE;
COMMIT;
-28
View File
@@ -1,28 +0,0 @@
BEGIN;
ALTER TABLE sales_order
ADD COLUMN market_occurrence_id BIGINT,
ADD COLUMN market_occurrence_day_id BIGINT;
ALTER TABLE market_occurrence_day
ADD CONSTRAINT uq_market_occurrence_day_occurrence_id UNIQUE (occurrence_id, id);
ALTER TABLE sales_order
ADD CONSTRAINT fk_sales_order_market_occurrence
FOREIGN KEY (market_occurrence_id)
REFERENCES market_occurrence (id)
ON DELETE RESTRICT,
ADD CONSTRAINT fk_sales_order_market_occurrence_day
FOREIGN KEY (market_occurrence_id, market_occurrence_day_id)
REFERENCES market_occurrence_day (occurrence_id, id)
ON DELETE RESTRICT,
ADD CONSTRAINT chk_sales_order_market_link_complete
CHECK ((market_occurrence_id IS NULL) = (market_occurrence_day_id IS NULL));
CREATE INDEX idx_sales_order_market_occurrence
ON sales_order (market_occurrence_id);
CREATE INDEX idx_sales_order_market_occurrence_day
ON sales_order (market_occurrence_day_id);
COMMIT;
@@ -1,55 +0,0 @@
BEGIN;
CREATE TABLE market_organizer (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_organizer_status CHECK (status IN ('active', 'inactive'))
);
CREATE UNIQUE INDEX uq_market_organizer_name ON market_organizer (LOWER(TRIM(name)));
INSERT INTO market_organizer (name)
VALUES ('Beispielveranstalter 1');
ALTER TABLE market_definition
ADD COLUMN organizer_id BIGINT;
UPDATE market_definition
SET organizer_id = (SELECT id FROM market_organizer WHERE name = 'Beispielveranstalter 1');
ALTER TABLE market_definition
ALTER COLUMN organizer_id SET NOT NULL,
ADD CONSTRAINT fk_market_definition_organizer
FOREIGN KEY (organizer_id) REFERENCES market_organizer(id) ON DELETE RESTRICT;
CREATE INDEX idx_market_definition_organizer ON market_definition (organizer_id);
ALTER TABLE market_occurrence_organizer
ADD COLUMN contact_person_name TEXT,
ADD COLUMN contact_email TEXT,
ADD COLUMN contact_phone TEXT;
UPDATE market_occurrence_organizer oo
SET contact_person_name = NULLIF(TRIM(CONCAT_WS(' ', c.first_name, c.last_name)), ''),
contact_email = c.email,
contact_phone = c.phone
FROM contact c
WHERE c.id = oo.contact_person_id;
ALTER TABLE market_occurrence_organizer
DROP COLUMN organization_contact_id,
DROP COLUMN contact_person_id;
INSERT INTO market_occurrence_organizer (occurrence_id, contact_person_name, created_at, updated_at)
SELECT o.id, NULL, NOW(), NOW()
FROM market_occurrence o
WHERE NOT EXISTS (
SELECT 1 FROM market_occurrence_organizer oo WHERE oo.occurrence_id = o.id
);
COMMIT;
@@ -1,40 +0,0 @@
BEGIN;
CREATE TABLE market_municipality (
id BIGSERIAL PRIMARY KEY,
municipality_reference_id BIGINT UNIQUE REFERENCES market_municipality_reference(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
canton_code TEXT,
bfs_municipality_number INTEGER,
data_version DATE,
source TEXT NOT NULL DEFAULT 'local',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_municipality_source CHECK (source IN ('reference', 'local'))
);
CREATE UNIQUE INDEX uq_market_municipality_name
ON market_municipality (LOWER(TRIM(name)));
CREATE INDEX idx_market_municipality_name
ON market_municipality (name);
INSERT INTO market_municipality (
municipality_reference_id, name, canton_code, bfs_municipality_number, data_version, source, is_active
)
SELECT id, name, canton_code, bfs_municipality_number, data_version, 'reference', is_active
FROM market_municipality_reference;
ALTER TABLE market_occurrence
DROP CONSTRAINT market_occurrence_municipality_reference_id_fkey;
UPDATE market_occurrence o
SET municipality_reference_id = m.id
FROM market_municipality m
WHERE m.municipality_reference_id = o.municipality_reference_id;
ALTER TABLE market_occurrence
ADD CONSTRAINT fk_market_occurrence_municipality
FOREIGN KEY (municipality_reference_id) REFERENCES market_municipality(id) ON DELETE RESTRICT;
COMMIT;
-63
View File
@@ -1,63 +0,0 @@
BEGIN;
CREATE TABLE market_cost_type (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO market_cost_type (code, name, is_system)
VALUES ('stand_fee', 'Standkosten', TRUE);
ALTER TABLE market_occurrence_cost_item
ADD COLUMN cost_type_id BIGINT REFERENCES market_cost_type(id) ON DELETE RESTRICT;
INSERT INTO market_cost_type (code, name)
SELECT DISTINCT
'legacy_' || md5(trim(cost_type)),
trim(cost_type)
FROM market_occurrence_cost_item
WHERE trim(cost_type) <> ''
AND lower(trim(cost_type)) <> 'standkosten'
ON CONFLICT (name) DO NOTHING;
UPDATE market_occurrence_cost_item item
SET cost_type_id = type.id
FROM market_cost_type type
WHERE type.name = CASE
WHEN lower(trim(item.cost_type)) = 'standkosten' THEN 'Standkosten'
ELSE trim(item.cost_type)
END;
UPDATE market_occurrence_cost_item item
SET cost_type_id = type.id
FROM market_cost_type type
WHERE type.code = 'stand_fee'
AND item.id = (
SELECT MIN(first_item.id)
FROM market_occurrence_cost_item first_item
WHERE first_item.occurrence_id = item.occurrence_id
);
INSERT INTO market_occurrence_cost_item (occurrence_id, cost_type_id, description, gross_amount, tax_status, tax_rate)
SELECT occurrence.id, type.id, NULL, 0.00, 'taxable', 8.10
FROM market_occurrence occurrence
JOIN market_cost_type type ON type.code = 'stand_fee'
WHERE NOT EXISTS (
SELECT 1 FROM market_occurrence_cost_item item WHERE item.occurrence_id = occurrence.id
);
ALTER TABLE market_occurrence_cost_item
ALTER COLUMN cost_type_id SET NOT NULL;
ALTER TABLE market_occurrence_cost_item
DROP COLUMN cost_type;
CREATE INDEX idx_market_occurrence_cost_item_type
ON market_occurrence_cost_item(cost_type_id);
COMMIT;
@@ -1,11 +0,0 @@
BEGIN;
UPDATE market_occurrence_cost_item item
SET tax_status = 'taxable', tax_rate = 8.10, updated_at = NOW()
WHERE item.id = (
SELECT MIN(first_item.id)
FROM market_occurrence_cost_item first_item
WHERE first_item.occurrence_id = item.occurrence_id
);
COMMIT;
@@ -1,8 +0,0 @@
BEGIN;
-- Reisedauer wird fachlich nicht mehr verwendet und vollständig entfernt.
ALTER TABLE market_occurrence
DROP CONSTRAINT IF EXISTS chk_market_occurrence_travel_duration,
DROP COLUMN IF EXISTS travel_duration_minutes;
COMMIT;
@@ -1,2 +0,0 @@
ALTER TABLE market_occurrence
ADD COLUMN registration_note TEXT;
@@ -1,33 +0,0 @@
BEGIN;
CREATE TABLE market_occurrence_checklist (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL UNIQUE REFERENCES market_occurrence(id) ON DELETE RESTRICT,
comment TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE market_occurrence_checklist_item (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
position INTEGER NOT NULL,
label TEXT NOT NULL,
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_checklist_item_position CHECK (position > 0)
);
CREATE INDEX idx_market_occurrence_checklist_item_occurrence
ON market_occurrence_checklist_item(occurrence_id, position, id);
INSERT INTO market_occurrence_checklist (occurrence_id)
SELECT id FROM market_occurrence;
INSERT INTO market_occurrence_checklist_item (occurrence_id, position, label)
SELECT occurrence_id, position, 'Position ' || position
FROM market_occurrence_checklist
CROSS JOIN generate_series(1, 30) AS positions(position);
COMMIT;
@@ -1,53 +0,0 @@
BEGIN;
ALTER TABLE market_occurrence_checklist_item
ADD COLUMN chapter_key TEXT;
DELETE FROM market_occurrence_checklist_item;
UPDATE market_occurrence_checklist
SET comment = NULL,
updated_at = NOW();
ALTER TABLE market_occurrence_checklist_item
ALTER COLUMN chapter_key SET NOT NULL,
ADD CONSTRAINT chk_market_occurrence_checklist_item_chapter
CHECK (chapter_key IN ('products_sales', 'decoration', 'stand_equipment', 'technology'));
INSERT INTO market_occurrence_checklist_item
(occurrence_id, chapter_key, position, label, is_completed)
SELECT checklist.occurrence_id,
defaults.chapter_key,
defaults.position,
defaults.label,
FALSE
FROM market_occurrence_checklist checklist
CROSS JOIN (
VALUES
('products_sales', 1, 'Fläschchen'),
('products_sales', 2, 'Säckchen'),
('products_sales', 3, 'Dunkle Kleber'),
('products_sales', 4, 'Naurua-Kleber'),
('products_sales', 5, 'Anwendungsbeilage'),
('products_sales', 6, 'Flyer Naurua'),
('products_sales', 7, 'Flyer Vitalpilze'),
('products_sales', 8, 'Vitalpilzbuch'),
('products_sales', 9, 'SumUp-Terminal'),
('products_sales', 10, 'Bargeld'),
('decoration', 1, 'Dekoration'),
('decoration', 2, 'Pilze'),
('decoration', 3, 'Blumenvase'),
('decoration', 4, 'Blume'),
('stand_equipment', 1, 'Tisch'),
('stand_equipment', 2, 'Tischtücher'),
('stand_equipment', 3, 'Beleuchtung'),
('stand_equipment', 4, 'Holzklötze'),
('stand_equipment', 5, 'Wagen'),
('stand_equipment', 6, 'Sonnenschirm'),
('stand_equipment', 7, 'Parkkarte/-Marktinformationen'),
('technology', 1, 'Batterie-/Powerbank fürs Handy')
) AS defaults(chapter_key, position, label);
CREATE INDEX idx_market_occurrence_checklist_item_chapter
ON market_occurrence_checklist_item(occurrence_id, chapter_key, position, id);
COMMIT;
@@ -20,7 +20,7 @@ Es werden keine vorbereiteten Shopify-Geschäftsdaten an nachgelagerte Module ü
## 4. Exakter Prozessablauf
1. `cutoff_timestamp` und `dry_run` validieren; beim Cron den kleineren Wert aus Wasserstand und ältestem offenen Webhook-Eingang verwenden und offene Webhook-Order-GIDs zusätzlich direkt zur Recovery aufnehmen.
1. `cutoff_timestamp` und `dry_run` validieren; beim Cron den kleineren Wert aus Wasserstand und ältestem offenen Webhook-Eingang verwenden.
2. Read-only Shopify-Bestellungen mit `createdAt` nach dem Stichtag in stabiler Reihenfolge laden.
3. Stornierte oder vollständig erstattete Bestellungen als nicht lagerwirksam klassifizieren und nur technisch protokollieren.
4. Für jede qualifizierte Bestellung die Shopify Order-GID idempotent gegen `sales_order.shopify_order_gid` prüfen.
@@ -43,7 +43,7 @@ Write targets:
## 5. Batch, Betriebsmodell und Einbettung
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere tägliche Reconciliation-Läufe nutzen den persistierten Wasserstand und ziehen offene oder fehlgeschlagene Webhook-Eingänge sowohl über den Prüfbereich als auch über ihre stabile Order-GID wieder in die Verarbeitung. Ein technisches Event wird erst nach erfolgreicher Projektion als verarbeitet markiert. Der Wasserstand wird nur nach einem vollständig erfolgreichen Lauf fortgeschrieben. Keine Parallelisierung.
Der Initiallauf verarbeitet den festen Zeitraum nach dem bestehenden ERP-Stichtag seriell. Spätere tägliche Reconciliation-Läufe nutzen den persistierten Wasserstand und ziehen offene oder fehlgeschlagene Webhook-Eingänge wieder in den Prüfbereich. Der Wasserstand wird nur nach einem vollständig erfolgreichen Lauf fortgeschrieben. Keine Parallelisierung.
## 6. Output
-3
View File
@@ -42,9 +42,6 @@ Bestand, Charge, MHD und Bewegungen.
## Technisch umgesetzt
- `modules/erp/lager/service.php`
- Read-only MCP für Hermes: `modules/erp/lager/mcp.php`
- MCP-Endpoint: `public/api/erp-lager-mcp.php`
- [ERP-Lager-Hermes-MCP-API](konzepte/erp_lager_hermes_mcp_api.md)
Hersteller-Stammdaten gehören zum Modul `kontakte`. Eine reale Lagercharge kann dort einen Hersteller und dessen Hersteller-Chargennummer referenzieren; die interne Naurua-Chargennummer bleibt Eigentum des Moduls `lager`. Virtuelle Fehlbestandschargen haben keinen Herstellerbezug.
@@ -1,31 +0,0 @@
# Hermes: ERP-Lager-MCP-API
## Zweck
Das ERP-Lager stellt ein eigenes, read-only MCP-Interface bereit. Hermes kann dadurch ausschließlich die Lager-Tools laden, ohne Marktmanagement-Tools in seinen Kontext aufzunehmen.
Endpoint: `https://erpnaurua.imhochrain.ch/api/erp-lager-mcp.php`
Authentifizierung: `Authorization: Bearer <MARKET_MCP_TOKEN>`
Der Token ist identisch mit dem Marktmanagement-MCP, weil derselbe Hermes-Bot auf beide fachlich getrennten Interfaces zugreift. Die Toolsets und Endpoints bleiben trotzdem getrennt.
## Verfügbare Tools
### `erp_lager.list_products`
Liefert alle aktiven Produkte mit ihren stabilen IDs.
### `erp_lager.get_product`
Liest alle Lagerdaten eines aktiven Produkts. Eingabe: `{ "product_id": 12 }`.
Die Antwort enthält die GUI-parallele Produktübersicht mit Produktstammdaten, aktueller und verfügbarer Lagermenge, Menge im Wareneingang, aktueller Charge, Ausverkaufsdatum und Forecast-Status, nächster aktiver offener Charge, weiteren offenen zukünftigen Chargen, historischen geschlossenen Chargen sowie virtueller Fehlbestandsmenge und virtueller Fehlbestandscharge.
Das Interface ist ausschließlich lesend. Es gibt keine Lager-MCP-Tools zum Anlegen, Ändern, Buchen, Verschieben oder Löschen.
## JSON-RPC
Das Interface unterstützt `initialize`, `notifications/initialized`, `tools/list` und `tools/call` gemäß dem bestehenden Marktmanagement-MCP-Transport.
Bei `tools/list` ist `inputSchema.properties` für jedes Werkzeug immer ein JSON-Objekt; beim parameterlosen `erp_lager.list_products` ist es `{}`.
@@ -4,7 +4,7 @@ Status: Verbindliche Prozess-Spezifikation
## 1. Zweck
Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums der aktuellen Charge und – sofern vorhanden – der qualifizierten, als nächste Charge markierten offenen Charge aktiver Produkte.
Tägliche Berechnung und Speicherung des voraussichtlichen Leer-Datums jeder aktuellen Charge aktiver Produkte.
## 2. Prozess-Einbettung
@@ -13,18 +13,18 @@ Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolg
## 3. Input
- optional eine einzelne `product_id` bei synchronem Verkaufstrigger
- aktive Produkte, aktuelle Charge, qualifizierte als nächste Charge markierte offene Charge, physische Chargenbestände und gültige Verkaufsallokationen aus dem eigenen Datenbestand
- aktive Produkte, aktuelle Chargen, physischer Chargenbestand und gültige Verkaufsallokationen aus dem eigenen Datenbestand
## 4. Exakter Prozessablauf
1. Bei einem Verkaufstrigger die betroffenen Produkt-IDs aus der erfolgreichen Allokation übernehmen; beim Cron-Trigger alle aktiven Produkte bestimmen.
2. Aktive Produkte, ihre aktuelle Charge und die qualifizierte, als nächste Charge markierte offene Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden. Für jede Charge wird nur ihr eigener positiver Nettobestand berücksichtigt.
2. Aktive Produkte und ihre aktuelle Charge aus `product`, `stock_lot` und `v_stock_lot_balance` laden.
3. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
4. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
6. Den gewichteten Tagesabsatz ab dem ersten gültigen Verkaufstag innerhalb des geladenen Zeitraums mit einer Halbwertszeit von 60 Tagen berechnen; Tage vor dem ersten Verkauf werden nicht als Nullabsatz berücksichtigt.
7. Das Leer-Datum der aktuellen Charge aus ihrem physischen Bestand und der gewichteten Nachfrage berechnen. Das Leer-Datum der nächsten Charge wird mit derselben Nachfrage ab dem Leer-Datum der aktuellen Charge weitergerechnet; bei bereits leerer aktueller Charge beginnt es am aktuellen Datum.
8. Jede erfolgreiche Berechnung je Charge in `stock_lot_forecast_history` speichern und den jeweiligen Snapshot in `stock_lot` aktualisieren.
6. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
7. Für jede aktuelle Charge das Leer-Datum aus physischem Bestand und gewichteter Nachfrage berechnen.
8. Jede erfolgreiche Berechnung in `stock_lot_forecast_history` speichern und den aktuellen Snapshot in `stock_lot` aktualisieren.
9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
## 5. Batch, Betriebsmodell und Einbettung
@@ -37,8 +37,7 @@ Ein normalisiertes JSON-Ergebnis mit Gesamtzahl, erfolgreich verarbeiteten Produ
## 7. Erfolgskriterien
- Jede aktuelle Charge erhält täglich einen Prognosesnapshot nur für ihren eigenen Bestand.
- Eine qualifizierte, als nächste Charge markierte offene Charge erhält einen separaten Prognosesnapshot nur für ihren eigenen Bestand.
- Jede aktuelle Charge erhält täglich einen aktuellen Prognosesnapshot.
- Verkäufe ohne Bestand über virtuelle `reserved`-Allokationen beeinflussen die Nachfrage.
- Die vollständige Berechnungshistorie bleibt erhalten.
- Ein einzelner Produktfehler verhindert keine weiteren Berechnungen.
-67
View File
@@ -1,67 +0,0 @@
# Market Management
Stand: 2026-09-15
Status: Fachliches Zielbild / Anforderungen in Ausarbeitung
## Zweck
Das Modul verwaltet Märkte von der Recherche und Prüfung über Anfrage, Anmeldung
und Durchführung bis zur Kosten-, Zahlungs- und Erlösauswertung.
## Fachliche Ownership
Das Modul soll die fachliche Ownership für Marktstammdaten, Markttermine,
Teilnahme- und Durchführungsstatus, marktbezogene Kosten und Zahlungen sowie
Erlös- und Tagesauswertungen tragen.
## Zugriffsmodell
Die Nutzung des Moduls ist an eine Benutzeridentität gebunden. Das Modul soll
separat von der übrigen Anwendung nutzbar und an weitere Personen verkaufbar
sein.
Die Ownership der Lizenz-/Entitlement-Daten ist noch nicht entschieden und
bleibt offen.
## MCP-Schnittstelle zu Hermes
Die App stellt die MCP-Server-Schnittstelle bereit. Hermes Agent ist MCP-Client
und ruft ausschließlich freigegebene Funktionen des Marktmanagement-Moduls
auf.
Die App behält die Kontrolle über:
- Authentifizierung und Berechtigungsprüfung
- Validierung und fachliche Interpretation der Eingaben
- erlaubte Schreiboperationen
- Persistierung in der eigenen Datenbank
- Audit- und Quelleninformationen
Hermes erhält keinen direkten Zugriff auf die Datenbank. Das Zielbild und die
Schnittstellenentscheidungen sind im
[market_management_hermes_mcp_konzept.md](konzepte/market_management_hermes_mcp_konzept.md)
dokumentiert.
## Abgrenzung
- Zunächst keine eigene Benutzerverwaltung.
- Keine eigene technische Runtime- oder Scheduler-Logik.
- Fachlich neutrale UI- und technische Querschnittsbausteine gehören nach
`shared`.
## Dokumente
- [Anforderungsprofil](requirements/market_management_requirements.md)
- [Datenarchitektur](data/market_management_data_architecture.md)
- [Technisches Tabellen- und Prozesskonzept](konzepte/market_management_technisches_konzept.md)
- [Hermes-MCP-Konzept](konzepte/market_management_hermes_mcp_konzept.md)
- [DB-Ownership-Konzept](konzepte/market_management_db_ownership.md)
- Prozessdokumente werden unter `processes/main/` und
`processes/sub_processes/` angelegt, sobald die fachlichen Prozesse explizit
definiert sind.
## Offene Architekturentscheidung
Die Ownership und konkrete Ablage der Lizenz-/Entitlement-Daten ist offen.
Diese Entscheidung darf bei der weiteren Umsetzung nicht stillschweigend
vorweggenommen werden.
@@ -1,106 +0,0 @@
# market_management_data_architecture
Stand: 2026-09-18
Status: Bestätigtes Datenarchitektur-Zielbild
## 1. Ziel
Das Marktmanagement-Modul muss wiederkehrende Märkte mit mehreren konkreten
Durchführungen und mehreren Tagen je Durchführung ohne redundante Vermischung
der Daten abbilden können.
## 2. Bestätigte Hierarchie
```text
Kategorie 1 ─── n Marktdefinition 1 ─── n Durchführung 1 ─── n Durchführungstag
```
Eine Kategorie enthält beliebig viele Marktdefinitionen. Jede Marktdefinition
gehört genau einer Kategorie.
### Kategorie
Eine Kategorie ordnet Märkte fachlich. Fabienne darf die deterministische
Kategorie-/Marktartliste erweitern. Hermes darf bestehende Kategorien
verwenden, aber keine Kategorien anlegen.
### Marktdefinition
Eine Marktdefinition ist die übergeordnete fachliche Identität, unter der
mehrere konkrete Durchführungen geführt werden können. Der zentrale Marktstamm
enthält nur den eindeutigen Marktnamen und die Marktart/Kategorie.
### Durchführung
Eine Durchführung ist eine konkrete Ausgabe bzw. Teilnahme an einem Markt. Die
Durchführung kann einen oder mehrere Durchführungstage umfassen.
### Durchführungstag
Ein Durchführungstag ist ein einzelner Tag innerhalb einer konkreten
Durchführung. Tagesbezogene Zeit-, Erlös- und Verkaufsdaten gehören auf diese
Ebene.
## 3. Feld- und Referenzwert-Zuordnung
### Zentraler Marktstamm
- Marktname
- Marktart/Kategorie aus einer deterministischen Liste
- optionale Website-URL der Veranstaltung
### Durchführung
Alle übrigen veränderlichen Marktangaben werden pro Durchführung erfasst,
insbesondere Kontakt, Ort, Termine, Anmeldung, Status, Stand, Infrastruktur,
Logistik, Kosten, Zahlung, Vertragslink und Notizen.
### Durchführungstag
- Datum des Durchführungstags
- Standzeit und tagesbezogene Durchführungszeiten
- Bruttoerlös aus zugeordneten OTC-Verkäufen
- Anzahl verkaufter Artikel aus zugeordneten OTC-Positionen
- Ereignisse und Beobachtungen
### Deterministische Referenzwerte
- Marktart/Kategorie
- Marktstatus
- Zahlungsstatus
- Zahlungsart
- Kanton
- standardisierte Ja/Nein-Merkmale für Infrastrukturangaben
### Abgeleitete Werte
- Wochentag aus dem Datum
- Saison aus dem Durchführungsdatum
- Gesamtkosten aus dem erfassten Bruttobetrag
- offener Betrag aus Kosten und bezahltem Betrag
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
Durchführungstagen
## 4. Ownership- und Normalisierungsregeln
- Jede Ebene erhält eine eigene stabile Identität.
- Mehrere Durchführungstage werden nicht als wiederholte Spalten in der
Durchführung gespeichert.
- Mehrere Durchführungen werden nicht als wiederholte Spalten im Markt
gespeichert.
- Tageserlöse und Tagesverkaufszahlen werden nur für den Durchführungstag aus
zugeordneten OTC-Verkäufen abgeleitet.
- Aggregierte Werte für Durchführung oder Markt werden aus den untergeordneten
Durchführungstagen abgeleitet oder als klar definierte Auswertung bereitgestellt.
- MCP-Schreiboperationen müssen eindeutig an die betroffene Ebene gebunden sein.
- Fremde Modul- oder DB-Ownership wird durch diese Datenarchitektur nicht
eingeführt.
## 5. Bestätigte Ergänzungen
- Eine Durchführung ist eine konkrete Teilnahme bzw. Veranstaltungsausgabe.
- Jeder Durchführungstag ist eine eigene Subdurchführung mit eigenen OTC-
Verkäufen und abgeleiteten Tageswerten.
- Markt und Durchführung werden unabhängig voneinander archiviert und
reaktiviert.
- Der Zahlungsstatus ist manuell und besitzt nur `paid` oder `unpaid`.
@@ -1,65 +0,0 @@
# Market Management: DB-Ownership-Konzept
Stand: 2026-09-19
Status: Verbindliches DB-Ownership-Konzept
## 1. Owned Tabellen des Moduls
| Tabelle | Ownership | Schreibverantwortung |
|---|---|---|
| `market_category` | `market_management` | Marktmanagement-Service |
| `market_definition` | `market_management` | Marktmanagement-Service |
| `market_occurrence` | `market_management` | Marktmanagement-Service |
| `market_occurrence_organizer` | `market_management` | Marktmanagement-Service über Kontakt-Schnittstelle |
| `market_occurrence_day` | `market_management` | Marktmanagement-Service |
| `market_occurrence_cost_item` | `market_management` | Marktmanagement-Service |
| `market_occurrence_checklist` | `market_management` | Submodul `occurrence_checklist` |
| `market_occurrence_checklist_item` | `market_management` | Submodul `occurrence_checklist` |
| `market_municipality_reference` | `market_management` | Referenzdatenprozess |
| `market_municipality` | `market_management` | Marktmanagement-Service |
| `market_mcp_idempotency` | `market_management` | MCP-Submodul |
## 2. Fremde Datenreferenzen
### Kontaktmodul
Das Marktmanagement-Modul speichert nur die stabilen IDs von Organisation und
Kontaktperson. Es schreibt keine Kontakt-, Organisations- oder Adressdaten in
fremde Tabellen.
### OTC-/Direktverkaufsmodul
Das OTC-Modul bleibt Owner seiner Verkaufstabellen. Jeder neue OTC-Verkauf
referenziert einen gültigen `market_occurrence_day_id`. Marktmanagement liest
die Verkaufsdaten ausschließlich über die definierte Schnittstelle für die
Umsatz- und Artikelmengenaggregation.
## 3. Schreibgrenzen
- Direkte Cross-Modul-Writes sind verboten.
- Marktmanagement schreibt nicht in OTC-, Kontakt- oder Organisations-Tabellen.
- Das OTC-Modul schreibt nicht in Marktmanagement-Tabellen.
- MCP schreibt nie direkt in Tabellen.
- Alle Marktmanagement-Writes laufen über den owning Modul-Service.
## 4. Referenzdaten
Der Gemeindereferenzbestand verwendet die BFS-Gemeindenummer als stabile
fachliche Referenz und speichert den Datenstand zur Nachvollziehbarkeit.
Das Marktmanagement besitzt zusätzlich das eigene Gemeinderegister
`market_municipality`. Dieses Register enthält die offiziellen Gemeinden sowie
lokal neu angelegte Gemeinden und ist die einzige Auswahl- und Schreibbasis für
Gemeindezuordnungen von Marktdurchführungen.
Die Kategorie-/Marktartliste gehört zum Marktmanagement und wird durch Fabienne
gepflegt. Hermes darf sie nur lesen.
## 5. Technische Änderungsregeln
- Tabellen- und Constraint-Änderungen erfolgen ausschließlich über versionierte
Migrationen.
- Fachliche Constraints werden im DB- und Prozessvertrag gespiegelt.
- Aggregierte Umsatzwerte werden nicht als unabhängige Fachwahrheit gespeichert.
- Archivierte Datensätze bleiben erhalten.
- Markt und Durchführung besitzen getrennte Archivstatus.
@@ -1,341 +0,0 @@
# Hermes: Market-Management-MCP-API
Stand: 2026-09-27
Status: Aktive technische Schnittstellendokumentation
Die Marktdurchführungs-Schnittstelle enthält kein Feld `travel_duration_minutes`.
Die Reisedauer wird nicht gespeichert, gelesen, geändert oder ausgegeben.
## Verbindung
Hermes verwendet den MCP-Endpunkt der DEV-App:
```text
https://erpnaurua.imhochrain.ch/api/market-management-mcp.php
```
Jeder Request ist ein JSON-RPC-2.0-Request über HTTPS. Der Request benötigt:
```http
Authorization: Bearer <MARKET_MCP_TOKEN>
Content-Type: application/json
```
Der Token wird nicht in dieser Dokumentation oder im Repository gespeichert.
## Verbindungsablauf
## Dokumentlinks in Marktdurchführungen
Wenn Hermes Informationen aus einer Datei verarbeitet und diese in einer
Marktdurchführung speichert, muss Hermes die Quelldatei als Link im Feld
`notes` aufnehmen. Der bestehende Text darf erhalten bleiben; die Position im
Freitext ist frei wählbar.
Der Link verwendet dieses Format:
```text
<a href="https://erpnaurua.imhochrain.ch/files.php?path=<RFC-3986-url-encodierter-relativer-Pfad>">Dokumentname</a>
```
Der Parameter `path` ist relativ zum Ordner `Hermes Naurua` und enthält nicht
den lokalen Mount-Prefix und nicht den Ordnernamen `Hermes Naurua` selbst.
Beispiel:
```text
Quelldatei: Hermes Naurua/Marktmanagement/2026 Bellevue Noël/Standmietvertrag_Naurua.pdf
Link: <a href="https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2F2026%20Bellevue%20No%C3%ABl%2FStandmietvertrag_Naurua.pdf">Standmietvertrag_Naurua.pdf</a>
```
Leerzeichen, Umlaute und sonstige Sonderzeichen im URL-Parameter müssen
vollständig URL-encodiert werden. Im Feld `notes` sind ausschließlich `<a>`
für solche internen Dokumentlinks sowie `<br>` und `<strong>` erlaubt. Andere
HTML-Tags und nicht erlaubte URL-Schemata werden entfernt. Externe `http://`-
und `https://`-Links sind erlaubt und öffnen in einem neuen Tab. Der Link ist
schreibgeschützt und erfordert bei internen Dokumenten eine aktive ERP-Anmeldung.
Hermes muss diesen HTML-String unverändert als Feldwert senden: keine Markdown-
Notation, keine Backticks und keine HTML-Entity-Kodierung wie `&lt;`, `&gt;` oder
`&quot;`. Der MCP-Transport erwartet den HTML-Inhalt im JSON-String und escaped
ihn nicht als HTML.
Die Route benötigt auf dem ERP-Webserver die Konfiguration
`HERMES_DOCUMENT_ROOT=/volume1/Hermes/Hermes Naurua`. Dieser lokale Pfad ist
die auf DEV verifizierte Ablage der Synology-Freigabe.
## Fachliche Begrifflichkeit für Hermes
Die fachliche Hierarchie lautet:
```text
Markt (market_definition)
└── Marktdurchführung (market_occurrence)
└── Markttag / Markttage (market_occurrence_day)
```
Ein Markt ist die zentrale Marktdefinition. Eine Marktdurchführung ist eine
konkrete Ausgabe bzw. Teilnahme an diesem Markt. Eine Marktdurchführung besitzt
mindestens einen Markttag. OTC-Verkäufe werden immer einem konkreten Markttag
zugeordnet; die zugehörige Marktdurchführung und der Markt ergeben sich aus
dieser Referenz.
### 1. Initialisieren
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26"
}
}
```
### 2. Werkzeuge laden
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
```
### 3. Werkzeug aufrufen
```json
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "market_management.list_categories",
"arguments": {}
}
}
```
Das Ergebnis steht bei `tools/call` als JSON im Textinhalt des ersten
`content`-Elements.
## Verfügbare Werkzeuge
### Marktdefinitionen
- `market_management.search_markets`
- optional: `search`, `category_id`, `status`, `place`,
`municipality_reference_id`, `date_from`, `date_to`, `include_archived`,
`sort`, `sort_direction`, `limit`, `offset`
- `market_management.search_occurrences`
- sucht Marktdurchführungen
- optional: `search`, `market_definition_id`, `status`, `place`, `address`,
`municipality_reference_id`, `date_from`, `date_to`, `include_archived`,
`sort`, `sort_direction`, `limit`, `offset`
- `market_management.get_market`
- erforderlich: `market_id`
- optional: `include_archived`
- liefert Marktdurchführungen inklusive Markttagen, Kosten und berechneten Verkaufswerten
- `market_management.create_market`
- erforderlich: `name`, `category_id`, `organizer_id`
- optional: `website_url` als HTTP(S)-URL des Marktes; Eingaben ohne Schema werden als `https://` normalisiert
- `market_management.list_market_organizers`
- liefert aktive Veranstalterorganisationen
- `market_management.create_market_organizer`
- erforderlich: `name`
- optional: `email`, `phone`
- `market_management.update_market`
- erforderlich: `market_id`
- änderbar: `name`, `category_id`, `website_url`
- `market_management.archive_market`
- erforderlich: `market_id`
- `market_management.reactivate_market`
- erforderlich: `market_id`
### Kategorien
- `market_management.list_categories`
Hermes darf Kategorien lesen und bei einem Markt referenzieren, aber keine
Kategorien anlegen oder ändern.
- `market_management.list_reference_data`
- liefert Kategorien, Kantone und aktive Gemeinden aus dem Markt-Gemeinderegister
- `market_management.create_municipality`
- erforderlich: `name`
- legt eine lokale Gemeinde im Markt-Gemeinderegister an
- `market_management.get_overview`
- liefert serverseitig gefilterte Markt- und Marktdurchführungslisten
### Marktdurchführungen
- `market_management.create_occurrence`
- erforderlich: `market_definition_id`, `days` mit mindestens einem Markttag
- initialer Status: `open`
- weitere Marktdurchführungsfelder sind optional und werden typisiert validiert
- legt automatisch eine Standard-Checkliste mit den vier festen Kapiteln an
- `market_management.get_occurrence`
- erforderlich: `occurrence_id`
- liefert zusätzlich den Markt, Markttage, Kosten und
berechnete Tages- und Gesamterlöse sowie `sales_items` je Markttag
- `market_management.get_occurrence_checklist`
- erforderlich: `occurrence_id`
- liefert die vier festen Kapitel, die Punkte mit `chapter_key` und den
Checklistenkommentar
- `market_management.update_occurrence_checklist`
- erforderlich: `occurrence_id`, `items`
- ersetzt die vollständige Checkliste atomar
- `items` enthält `chapter_key`, `label` und `is_completed`; `comment` ist optional und nullable
- feste Kapitel: `products_sales` (Produkte & Verkauf), `decoration` (Dekoration),
`stand_equipment` (Stand & Ausstattung), `technology` (Technik)
- Kapitel sind nicht editierbar; Hermes darf Punkte ergänzen, entfernen,
umbenennen, abhaken und zurücksetzen
- `market_management.update_occurrence`
- erforderlich: `occurrence_id`
- änderbar: sämtliche editierbaren GUI-Marktdurchführungsfelder einschließlich
Gemeinde, Kontaktperson, Kontakt-E-Mail, Kontakt-Telefon und `registration_note`
- der vom Markt geerbte Veranstalter ist nicht editierbar
Das optionale Feld `registration_note` speichert den Freitext-Hinweis zur
Anmeldung einer Marktdurchführung. Es wird bei `create_occurrence`,
`get_occurrence` und `update_occurrence` als nullable String verarbeitet.
- `market_management.archive_occurrence`
- erforderlich: `occurrence_id`
- `market_management.reactivate_occurrence`
- erforderlich: `occurrence_id`
Unterstützte Statuswerte:
```text
open
waitlist
registered
confirmation_pending
confirmed
cancelled
completed
```
Unterstützte Zahlungsstatuswerte:
```text
unpaid
paid
```
### Markttage
- `market_management.create_day`
- legt einen Markttag an
- erforderlich: `occurrence_id`, `date`
- `market_management.update_day`
- erforderlich: `day_id`
- `market_management.delete_day`
- erforderlich: `day_id`
Weitere mögliche Tagesfelder sind `stand_start_time`, `stand_end_time`,
`setup_start_time`, `setup_end_time`, `delivery_start_time`,
`delivery_end_time` und `observations`.
### Veranstalter und Kosten
- Veranstalter werden am Markt über `organizer_id` gesetzt und von den
Durchführungen geerbt. Marktdurchführungen können nur `contact_person_name`,
`contact_email` und `contact_phone` ändern.
- Kosten werden bei `market_management.create_occurrence` automatisch mit einer
`Standkosten`-Position (`stand_fee`) und dem Standardsteuersatz 8.1 % angelegt.
- `market_management.update_occurrence` ändert Kosten über `gross_cost`,
`tax_status`, `tax_rate` sowie die optionale mehrzeilige Freitext-Aufschlüsselung
`cost_items_text`.
- `cost_items_text` wird beim Schreiben als Beschreibung der automatisch
verwalteten `stand_fee`-Kostenposition gespeichert. In den MCP-Leseantworten
wird derselbe Wert zusätzlich als `cost_items_text` auf der Durchführung
ausgegeben; damit entspricht er direkt dem GUI-Feld „Kostenpositionen“.
- `tax_status` ist `taxable` oder `tax_free`; bei `tax_free` ist der Steuersatz
`0.00`.
- `gross_cost` darf `0.00` sein; einzelne Kostenpositionen und Kostenarten werden
nicht separat über MCP verwaltet.
## Beispiel: Markt anlegen
```json
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "market_management.create_market",
"arguments": {
"name": "Beispielmarkt Hermes Test",
"category_id": 1,
"idempotency_key": "hermes-create-market-001"
}
}
}
```
## Beispiel: Marktdurchführung mit Markttag anlegen
```json
{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "market_management.create_occurrence",
"arguments": {
"market_definition_id": 1,
"status": "open",
"place": "Beispielort",
"registration_deadline": "2026-10-01",
"idempotency_key": "hermes-create-occurrence-001",
"days": [
{
"date": "2026-11-21",
"stand_start_time": "09:00",
"stand_end_time": "18:00"
}
]
}
}
}
```
```json
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "market_management.create_day",
"arguments": {
"occurrence_id": 1,
"date": "2026-11-21",
"stand_start_time": "09:00",
"stand_end_time": "18:00"
}
}
}
```
## Fachliche Grenzen
- Hermes erhält keinen direkten Datenbankzugriff.
- Hermes darf keine Kategorien anlegen.
- Umsatz und Artikelmengen werden nicht über MCP geschrieben; sie werden aus
den OTC-Verkäufen abgeleitet.
- `get_occurrence` liefert `sales_items` mit `day_id`, `date`, `product_id`,
`sku`, `product_name`, `quantity`, `gross_revenue` und `unit_price`.
- Markt und Marktdurchführung werden unabhängig archiviert.
- Alle Schreibvorgänge werden serverseitig validiert.
- Jeder schreibende MCP-Aufruf benötigt `idempotency_key`; das Feld ist in den
Schemas aller Schreibwerkzeuge als Pflichtfeld deklariert. Optional kann
`client_id` mitgegeben werden. Wiederholungen liefern dasselbe Ergebnis.
- Die gelieferten Verkaufspositionen stammen ausschließlich aus OTC-Verkäufen,
die einem konkreten Markttag zugeordnet sind.
## Fehler
Bei fehlender oder ungültiger Authentifizierung antwortet der Endpunkt mit
HTTP 401. Ungültige JSON-RPC-Requests oder fachlich ungültige Eingaben werden
als JSON-RPC-Fehlerantwort zurückgegeben.
@@ -1,102 +0,0 @@
# Market Management: Hermes-MCP-Konzept
Stand: 2026-09-19
Status: Verbindliches Schnittstellenkonzept
## 1. Zweck
Die App stellt einen geschützten MCP-Server bereit. Hermes Agent ist MCP-Client
und arbeitet im Benutzerkontext. Hermes kann Marktmanagement-Daten suchen,
anlegen, ändern, archivieren und reaktivieren.
## 2. Verantwortungen
Die App kontrolliert:
- Authentifizierung
- Benutzer- und Modulberechtigung
- Eingabevalidierung
- fachliche Feldzuordnung
- Persistierung
- Audit- und Herkunftsdaten
- Idempotenz
Hermes besitzt keinen direkten Zugriff auf Datenbank, interne Services oder
fremde Module.
## 3. Schnittstellenmodell
Der MCP-Server veröffentlicht fachliche Tools, keine generischen Datenbank-
oder Tabellenoperationen.
Vorgesehene Toolgruppen:
- Märkte suchen und lesen
- Märkte anlegen, ändern, archivieren und reaktivieren
- Marktdurchführungen suchen und lesen
- Marktdurchführungen anlegen, ändern, archivieren und reaktivieren
- Markttage anlegen, ändern und entfernen
- Checklisten einer Marktdurchführung lesen und vollständig ändern
- Kategorien, Kantone und Gemeinden lesen
- Kosten und Zahlungsstatus ändern
- Übersichtsdaten lesen
Hermes darf bestehende Kategorien verwenden, aber keine Kategorien anlegen.
Umsatz- und Artikelmengen werden nicht über Hermes geschrieben, sondern aus den
OTC-Verkäufen abgeleitet.
## 4. Authentifizierung und Autorisierung
- MCP-Endpunkt über geschütztes HTTPS
- Bearer-Token-Prüfung an der Transportgrenze
- der konfigurierte Token identifiziert den technischen Hermes-Benutzerkontext
- zusätzliche Prüfung im jeweiligen Tool-Handler
- kein direkter Datenbankzugriff für Hermes
Für die erste Implementierungsstufe wird bewusst keine neue OAuth-/OIDC- oder
Entitlement-Infrastruktur eingeführt. Der Token wird ausschließlich über die
geschützte App-Konfiguration bereitgestellt und mit konstanter Zeit verglichen.
Die Implementierung verwendet dafür den Konfigurationswert
`MARKET_MCP_TOKEN`.
Die fachliche Ownership der Lizenz-/Entitlement-Daten bleibt offen. Die
Schnittstelle prüft nur die über den bestehenden Zugriffskontext gelieferte
Modulberechtigung.
## 5. Schreibregeln
- Jeder Tool-Aufruf ist atomar.
- Fehlgeschlagene Aufrufe erzeugen keine Teilpersistierung.
- Alle Schreibvorgänge laufen über die interne Modul-API.
- Checklisten-Schreibvorgänge ersetzen die vollständige Checkliste atomar. Hermes darf Punkte ergänzen, entfernen, umbenennen, abhaken und zurücksetzen sowie den Checklistenkommentar ändern.
- Hermes darf bei einem neuen Markt nur eine bestehende Kategorie referenzieren.
- Wenn Hermes Informationen aus einer Datei verarbeitet und in einer
Marktdurchführung speichert, muss die Quelldatei als interner `<a>`-Link im
Feld `notes` referenziert werden. Zusätzlich sind in mehrzeiligen Feldern
`<br>` und `<strong>` erlaubt. Die Position im bestehenden Freitext ist frei
wählbar. Externe `http://`- und `https://`-Links sind zulässig und öffnen in
einem neuen Tab; andere HTML-Tags und URL-Schemata sind nicht zulässig. Hermes
sendet den HTML-String unverändert; Markdown, Backticks und HTML-Entities wie
`&lt;`, `&gt;` oder `&quot;` werden nicht verwendet. Der MCP-Transport escaped
den HTML-Inhalt nicht.
- Unsichere Erkennung wird nicht durch eine zusätzliche App-Freigabelogik
behandelt; die fachliche Qualitätssicherung liegt im Hermes-Training.
## 6. Wiederholungsschutz
Schreibende Aufrufe können eine Idempotenzreferenz übergeben. Die App speichert
Client, Benutzer, Tool, Schlüssel, Request-Hash und Verarbeitungsergebnis in
`market_mcp_idempotency`.
Ein wiederholter Aufruf mit gleichem Schlüssel und gleichem Request liefert das
bereits bekannte Ergebnis. Ein gleicher Schlüssel mit verändertem Request wird
abgewiesen.
## 7. Daten- und Modulgrenzen
- Marktmanagement schreibt nur in eigene Tabellen.
- Das OTC-Modul schreibt die Verkaufstabelle und referenziert den
`market_occurrence_day_id`.
- Kontakt- und Organisationsdaten werden über das Kontaktmodul referenziert.
- Gemeindedaten werden über den eigenen Referenzbestand gelesen.
- MCP-Komposition enthält keine Marktfachlogik.
@@ -1,414 +0,0 @@
# Market Management: Normatives Tabellen- und Prozesskonzept
Stand: 2026-09-19
Status: Verbindliches Konzept für das Modul `market_management`
## 1. Übersicht
Tabellen:
- `market_category`
- `market_definition`
- `market_occurrence`
- `market_occurrence_organizer`
- `market_occurrence_day`
- `market_occurrence_cost_item`
- `market_occurrence_checklist`
- `market_occurrence_checklist_item`
- `market_municipality_reference`
- `market_municipality`
- `market_mcp_idempotency`
Bestehende Integrationsreferenz:
- `sales_order.market_occurrence_day_id` im owning OTC-/Direktverkaufsmodul
Prozessbereiche:
- zentrale Marktdefinition verwalten
- Durchführung verwalten
- Durchführungstage verwalten
- Kosten und Zahlungsstatus verwalten
- OTC-Verkäufe einem Durchführungstag zuordnen
- Umsatz- und Artikelmengen lesen und aggregieren
- Marktübersicht lesen
- Hermes-MCP-Zugriff ausführen
- Marktdurchführungs-Checkliste verwalten
- Referenzdaten verwalten
Die Checkliste gehört zur Marktdurchführung, nicht zum einzelnen Markttag.
Sie besitzt die vier festen Kapitel `Produkte & Verkauf`, `Dekoration`,
`Stand & Ausstattung` und `Technik`. Kapitel und Kapitelreihenfolge sind
fest; Punkte innerhalb eines Kapitels bleiben für Benutzer und Hermes
editierbar. Der Kommentar gehört zur gesamten Checkliste.
## 2. Zweck und Scope
Dieses Dokument beschreibt das normative technische Zielbild für die Umsetzung
bis einschließlich Punkt 10 des Anforderungsprofils.
Enthalten sind:
- zentrale Marktdefinition mit eindeutigem Namen und genau einer Kategorie
- beliebig viele Durchführungen je Markt
- mindestens ein Durchführungstag je Durchführung
- veranstaltungs-, tages-, kosten- und zahlungsbezogene Daten
- OTC-basierte Umsatz- und Artikelmengenermittlung
- Archivierung von Markt und Durchführung auf getrennten Ebenen
- serverseitige Übersichtsdaten
- Benutzer- und Modulberechtigung
- Hermes als MCP-Client der App
Nicht enthalten sind Punkt 11 und folgende, insbesondere intelligente
Auswertungen, Korrelationen und wirtschaftliche Gemeindedaten.
GUI-Details, konkrete HTTP-Komposition und technische Hermes-Implementierung
werden in den zugehörigen Detaildokumenten beschrieben.
## 3. Normatives Datenmodell
```text
Kategorie / Marktart
└── Marktdefinition
└── Durchführung
└── Durchführungstag / Subdurchführung
└── OTC-Verkäufe
```
### 3.1 `market_category`
Deterministische Kategorie-/Marktartliste.
Normativ:
- Kategorienamen sind eindeutig.
- Fabienne darf die Liste erweitern.
- Hermes darf bestehende Kategorien auswählen, aber keine Kategorien anlegen.
- Deaktivierte Kategorien bleiben für historische Zuordnungen erhalten.
### 3.2 `market_definition`
Zentrale fachliche Marktdefinition.
Normativ:
- jeder Markt besitzt genau einen eindeutigen Namen
- jeder Markt besitzt genau eine Kategorie
- jeder Markt besitzt genau einen verpflichtenden Veranstalter aus dem eigenen Markt-Veranstalterregister
- jeder Markt kann eine optionale Website-URL der Veranstaltung besitzen
- der Markt enthält keine Durchführungs-, Kontakt-, Kosten-, Zahlungs- oder
Erlösdaten
- Markt und Durchführungen werden getrennt archiviert und reaktiviert
### 3.3 `market_occurrence`
Konkrete Teilnahme bzw. Veranstaltungsausgabe eines Marktes.
Normativ:
- jede Durchführung gehört genau einer Marktdefinition
- jede Durchführung besitzt mindestens einen Durchführungstag
- variable Veranstaltungs-, Kontakt-, Orts-, Logistik-, Kosten- und
Zahlungsdaten gehören zur Durchführung
- der Status gehört zur Durchführung
- Zahlungsstatus und Marktstatus sind unabhängig
- der optionale `registration_note` gehört als Freitext zur Durchführung
- der Veranstalter wird von jeder Durchführung des Marktes geerbt und ist dort nicht editierbar
- der Zahlungsstatus ist manuell `paid` oder `unpaid`
Minimale Pflichtfelder über alle Statuswerte hinweg:
- `market_definition_id`
- initialer Status `open`
- mindestens ein Durchführungstag
### 3.4 `market_occurrence_organizer`
Veranstalterbeziehung einer Durchführung.
Normativ:
- die Tabelle enthält die durchführungsbezogene Kontaktperson als Freitext sowie optionale E-Mail und Telefonnummer
- jede Durchführung besitzt genau einen lokalen Kontaktdatensatz
- der Veranstalter selbst wird ausschließlich über `market_definition.organizer_id` vom Markt geerbt
- Veranstalter werden ausschließlich im Markt-Veranstalterregister verwaltet und nicht aus Lieferanten oder Kunden abgeleitet
### 3.5 `market_occurrence_day`
Einzelner Durchführungstag bzw. Subdurchführung.
Normativ:
- jeder Tag gehört genau einer Durchführung
- das Datum ist je Durchführung eindeutig
- Stand-, Aufbau-, Abbau- und tagesbezogene Anlieferungszeiten gehören hierher
- Ereignisse und Beobachtungen gehören hierher
- OTC-Verkäufe werden eindeutig diesem Datensatz zugeordnet
- Bruttoerlös und Artikelmenge werden aus den zugeordneten OTC-Verkäufen
abgeleitet und nicht manuell gespeichert
Minimale Pflichtfelder:
- `occurrence_id`
- `date`
### Mindestpflichtfelder
Über alle Statuswerte hinweg gelten nur diese Pflichtfelder:
- Marktdefinition: `name`, `category_id`; `website_url` ist optional
- Durchführung: `market_definition_id`, initialer Status `open`, mindestens ein
Durchführungstag
- Durchführungstag: `occurrence_id`, `date`
Alle weiteren Felder bleiben bei der Anlage optional und werden unabhängig vom
Status ergänzt.
### 3.6 Kosten einer Durchführung
Die Durchführung besitzt einen einfachen Gesamtkostenbetrag und eine optionale
mehrzeilige Freitext-Aufschlüsselung der Kostenpositionen.
Normativ:
- jede Durchführung besitzt genau eine technische `Standkosten`-Position
(`stand_fee`); deren Betrag darf `0.00` sein und der Standardsteuersatz beträgt
8.1 %
- weitere Einzelpositionen und Kostenarten werden nicht separat gepflegt
- CHF ist die einzige Währung
- Bruttobetrag wird erfasst
- Netto- und Steuerbetrag werden aus Brutto und Steuerart/-satz berechnet
- die Freitext-Aufschlüsselung wird als Beschreibung gespeichert
### 3.7 `market_municipality_reference`
Technischer Referenzbestand des offiziellen Schweizer Gemeindeverzeichnisses.
Normativ:
- Gemeinden werden über BFS-Gemeindenummern referenziert
- Gemeindename und Kantonszuordnung stammen aus dem Referenzbestand
- nur gültige Gemeinden stehen für neue Eingaben zur Auswahl
### 3.8 `market_municipality`
Eigenes Gemeinderegister des Marktmanagements. Es enthält sowohl aktive
Gemeinden aus dem offiziellen Referenzbestand als auch lokal neu angelegte
Gemeinden.
Normativ:
- jede aktive Gemeinde besitzt einen eindeutigen Namen
- Referenzgemeinden behalten ihre BFS-Gemeindenummer und den Datenstand
- lokal angelegte Gemeinden besitzen keine BFS-Referenz
- neue Gemeinden werden über das Marktmanagement angelegt und danach in der
Auswahl für weitere Marktdurchführungen angeboten
- eine Marktdurchführung referenziert genau einen Datensatz dieses Registers
- der Datenstand wird versioniert gespeichert
### 3.9 `market_mcp_idempotency`
Technischer Idempotenzbestand für wiederholte Hermes-Schreibaufrufe.
Normativ:
- derselbe Client und Idempotenzschlüssel erzeugen keine doppelte Wirkung
- ein abweichender Request mit demselben Schlüssel wird abgewiesen
- der gespeicherte Verarbeitungsausgang bleibt nachvollziehbar
### 3.9 OTC-Integration
Das OTC-Modul bleibt Owner seiner Verkaufstabellen. Jeder neue OTC-Verkauf
referenziert eindeutig einen `market_occurrence_day_id`. Markt, Durchführung,
Datum, Umsatz und Artikelmengen werden daraus ermittelt.
Historische OTC-Verkäufe ohne diese Zuordnung werden nicht migriert.
## 4. Normatives Prozessmodell
Alle fachlichen Prozesse gehören zum owning Modul `market_management`, außer der
OTC-Verkaufsprozess selbst. Die OTC-Integration erfolgt ausschließlich über die
öffentliche Marktmanagement-Schnittstelle.
### 4.1 Zentrale Marktdefinition verwalten
Fachliche Aufgabe:
- Marktdefinition mit eindeutigem Namen und Kategorie anlegen, ändern,
archivieren oder reaktivieren
Liest:
- Kategorien
- bestehende Marktdefinitionen
Schreibt:
- `market_category`, sofern Fabienne eine Kategorie erweitert
- `market_definition`
### 4.2 Durchführung verwalten
Fachliche Aufgabe:
- eine konkrete Durchführung eines Marktes anlegen, bearbeiten, archivieren
oder reaktivieren
Liest:
- Marktdefinition
- Kontaktmodul
- Gemeindereferenz
Schreibt:
- `market_occurrence`
- `market_occurrence_organizer`
### 4.3 Durchführungstage verwalten
Fachliche Aufgabe:
- Durchführungstage anlegen, bearbeiten und entfernen
Liest:
- Durchführung
Schreibt:
- `market_occurrence_day`
### 4.4 Kosten und Zahlungsstatus verwalten
Fachliche Aufgabe:
- Gesamtkosten, Kostenaufschlüsselung und manuelle Zahlungsinformationen pflegen
Liest:
- Durchführung
- bestehende Kosteninformation
Schreibt:
- technische Standkosten-Position in `market_occurrence_cost_item`
- Zahlungsfelder in `market_occurrence`
### 4.5 OTC-Verkauf einem Durchführungstag zuordnen
Fachliche Aufgabe:
- beim OTC-Verkauf einen gültigen Durchführungstag auswählen und referenzieren
Liest:
- verfügbare Märkte, Durchführungen und Durchführungstage über die
Marktmanagement-Schnittstelle
Schreibt:
- ausschließlich das OTC-Modul in seine eigene Verkaufstabelle
### 4.6 Umsatz- und Artikelmengen lesen
Fachliche Aufgabe:
- Umsatz und Artikelmengen für Tag, Durchführung und Markt aus den OTC-Daten
aggregieren
Liest:
- zugeordnete OTC-Verkäufe und Verkaufspositionen
- Markt-, Durchführungs- und Tagesdaten
Schreibt:
- keine fachlichen Umsatzsummen; die Werte sind abgeleitet
### 4.7 Marktübersicht lesen
Fachliche Aufgabe:
- Märkte, Durchführungen und Tage serverseitig suchen, filtern, sortieren und
aggregiert ausgeben
Liest:
- alle owned Marktmanagement-Daten
- Kontakt- und Gemeindereferenzen
- OTC-Aggregate
Schreibt:
- nichts
### 4.8 Hermes-MCP-Zugriff
Fachliche Aufgabe:
- Hermes über die kontrollierte MCP-Schnittstelle suchen, anlegen, ändern,
archivieren und reaktivieren lassen
Liest:
- Marktmanagement-Daten und erlaubte Referenzdaten
Schreibt:
- ausschließlich über die internen Marktmanagement-Services
- `market_mcp_idempotency` für Wiederholungsschutz
Die konkrete MCP-Schnittstelle ist in
[market_management_hermes_mcp_konzept.md](market_management_hermes_mcp_konzept.md)
dokumentiert.
## 5. Normative Betriebsregeln
- Marktname und Kategorie sind zentrale Stammdaten.
- Alle variablen Veranstaltungsdaten werden je Durchführung gepflegt.
- Tageswerte werden je Durchführungstag gepflegt.
- Wochentag, Saison, Datumsbereich, Gesamtkosten, offener Betrag, Umsatz und
Artikelmengen werden abgeleitet.
- Umsatz und Artikelmengen stammen ausschließlich aus OTC-Verkäufen.
- Stornos und Rückerstattungen werden in den Aggregaten abgezogen.
- Markt- und Durchführungarchivierung sind unabhängig.
- Der erste Status ist `offen`.
- `durchgefuehrt` wird nach dem letzten Durchführungstag automatisch gesetzt,
sofern die Durchführung nicht `abgesagt` ist.
- Die GUI enthält keine fachliche Primärlogik.
- Hermes erhält keinen direkten DB-Zugriff.
- Punkt 11 und folgende sind nicht Teil dieses Zielbilds.
## 6. Ausgabevertrag
Das Modul liefert:
- eindeutige Markt-, Durchführungs- und Tagesidentitäten
- validierte Stammdaten und Referenzen
- unabhängigen Markt- und Zahlungsstatus
- berechnete Kosten-, Umsatz- und Artikelmengenwerte
- serverseitig filter- und sortierbare Übersichtsdaten
- kontrollierte MCP-Ergebnisse für Hermes
## 7. Erfolgskriterien
- Marktdefinitionen sind eindeutig und genau einer Kategorie zugeordnet.
- Durchführungen und Durchführungstage sind unabhängig und vollständig
verwaltbar.
- Jeder neue OTC-Verkauf ist genau einem Durchführungstag zugeordnet.
- Umsatz und Artikelmengen werden nicht manuell doppelt erfasst.
- Kontakt- und Gemeindedaten werden über definierte Referenzen verwendet.
- Archivierung funktioniert getrennt für Markt und Durchführung.
- MCP-Schreibvorgänge sind authentifiziert, berechtigt und idempotent.
- Die fachliche Logik bleibt im owning Modul.
## 8. Noch nicht normative Detailverträge
Die folgenden Dokumente werden erst nach expliziter Prozessdefinition als
normative Verträge erstellt:
- Hauptprozesse unter `processes/main/`
- Sub-Prozesse unter `processes/sub_processes/`
- konkrete API-Schemas der Marktmanagement-Schnittstelle
- GUI- und View-Verträge
@@ -1 +0,0 @@
@@ -1,111 +0,0 @@
# market_management.occurrence_status_completion
Stand: 2026-09-19
Status: Verbindliche Prozess-Spezifikation
## 1. Zweck
Setzt aktive Durchführungen am täglichen Cron-Lauf automatisch auf
`completed`, sobald der Kalendertag nach ihrem letzten Durchführungstag erreicht
ist. Abgesagte Durchführungen werden nicht verändert.
## 2. Prozess-Einbettung
Täglicher synchroner Cron-Teilprozess des zentralen System-Cron-Entrypoints.
Die fachliche Statusänderung gehört zum owning Modul `market_management`.
## 3. Input
- aktuelles Datum in der Zeitzone `Europe/Zurich`
- keine vorbereiteten Fachdaten; der Prozess liest seine Daten selbst aus der
Datenbank
## 4. Exakter Prozessablauf
1. Einen technischen Lauf in `public.process_runs` mit dem aktuellen Datum
starten.
2. Aus `market_occurrence` und `market_occurrence_day` aktive Durchführungen
mit mindestens einem Tag bestimmen.
3. Durchführungen ausschliessen, deren Status `cancelled` oder bereits
`completed` ist.
4. Für jede verbleibende Durchführung den letzten Durchführungstag bestimmen.
5. Wenn dieser Tag vor dem aktuellen Datum liegt, den Status atomar auf
`completed` setzen.
6. Den technischen Lauf mit Anzahl und IDs der geänderten Durchführungen
abschliessen.
Gelesen werden `market_occurrence` und `market_occurrence_day`. Geschrieben
werden `market_occurrence.status`, `market_occurrence.updated_at` und der
technische Lauf in `public.process_runs`.
## 5. Batch, Betriebsmodell und Einbettung
Ein täglicher synchroner Cron-Lauf; ein SQL-Update verarbeitet den vollständigen
aktuellen Scope atomar. Die bestehende Cron-Sperre verhindert parallele Läufe.
## 6. Output
Ein kompakter Laufstatus mit aktuellem Datum, Anzahl und IDs der auf
`completed` gesetzten Durchführungen sowie der technischen Lauf-ID.
## 7. Erfolgskriterien
- Eine Durchführung wird am Tag nach ihrem letzten Durchführungstag auf
`completed` gesetzt.
- Abgesagte und bereits abgeschlossene Durchführungen bleiben unverändert.
- Durchführungen ohne Durchführungstag werden nicht verändert.
- Wiederholte Cron-Läufe sind idempotent.
- `public.process_runs` enthält einen abgeschlossenen technischen Lauf.
## 8. Fehlerschranke
Ein technischer Fehler bricht den Lauf ab, rollt die fachliche Transaktion
zurück und markiert `process_runs` als `failed`.
## 9. Fachliche Betriebsregel
Die Prüfung erfolgt einmal täglich über den bestehenden Cron. Die Statusänderung
ist unabhängig von GUI-Aufrufen und OTC-Bestellungen.
## 10. Sub-Prozess-Referenzen
Keine zusätzlichen Sub-Prozesse.
## 11. End-to-End Sub-Prozess-Kette
System-Cron → Statusabschlussprozess → `market_occurrence` und
`public.process_runs`.
## 12. Sub-Prozess-Wiederverwendung
Der bestehende System-Cron-Entrypoint und die bestehende Cron-Sperre werden
wiederverwendet. Ein zusätzlicher Scheduler wird nicht eingeführt.
## 13. Step-Liste
- Lauf starten
- eligible Durchführungen bestimmen und abschliessen
- Laufstatus persistieren
## 14. Parallelisierung
Keine Parallelisierung; der deterministische SQL-Batch läuft synchron und
atomar.
## 15. Output-Payload
```json
{
"status": "done",
"as_of_date": "YYYY-MM-DD",
"completed_count": 0,
"completed_occurrence_ids": [],
"technical_run_id": 0
}
```
## 16. Done-Kriterien
- Der tägliche Cron ruft den Prozess genau einmal auf.
- Die Statusregel ist serverseitig implementiert.
- Der Prozess schreibt `process_runs` vollständig.
- Wiederholte Läufe erzeugen keine erneuten Statusänderungen.
@@ -1,295 +0,0 @@
# Anforderungsprofil Marktübersicht
Stand: 2026-09-15
Status: Fachliche Arbeitsanforderung
## 1. Ziel des Systems
Das System unterstützt die Suche, Prüfung, Anmeldung, Durchführung und
Auswertung von Herbstmärkten, Weihnachtsmärkten und weiteren geeigneten
Märkten.
## 2. Umfang der ersten Phase
- Es wird jeweils nur ein Markt gleichzeitig bearbeitet.
- Zunächst gibt es nur eine Benutzerin: Fabienne.
- Eine eigene Benutzerverwaltung ist zunächst nicht erforderlich.
- Die Anwendung soll übersichtlich und einfach bedienbar sein.
## 3. Datenhierarchie
Die Daten müssen mehrstufig abbildbar sein:
```text
Kategorie
└── Markt
└── Durchführung
└── Durchführungstag
```
- Es können beliebig viele Kategorien und Märkte existieren.
- Ein Markt gehört genau einer Kategorie.
- Fabienne darf die deterministische Kategorie-/Marktartliste erweitern.
- Hermes darf bestehende Kategorien auswählen, aber keine Kategorien anlegen.
- Ein Markt kann beliebig viele Durchführungen enthalten.
- Eine Durchführung kann beliebig viele Durchführungstage enthalten.
- Erlös- und Verkaufsdaten werden auf Ebene des Durchführungstags erfasst und
auf Ebene der Durchführung sowie des Marktes auswertbar gemacht.
Der zentrale Marktstamm enthält ausschließlich:
- Marktname
- Marktart/Kategorie aus einer deterministischen Liste
- optionale Website-URL der Veranstaltung
Alle übrigen veränderlichen Angaben werden pro Durchführung erfasst. Tages-
und Verkaufsdaten werden pro Durchführungstag erfasst.
## 4. Nutzungsberechtigung
- Die Rechte zur Nutzung des Moduls werden an die Benutzeridentität geknüpft.
- Das Modul soll ohne die restliche Anwendung an weitere Personen verkauft und
separat genutzt werden können.
- Die Ownership und Ablage der Lizenz-/Entitlement-Daten ist noch nicht
entschieden und bleibt offen.
## 5. Marktarten
Initial verfügbare Marktarten:
- Herbstmärkte
- Weihnachtsmärkte
- Frühlingsmärkte
- Ostermärkte
- Kunsthandwerksmärkte
- Regionalmärkte
- Weitere geeignete Märkte
Die Marktart/Kategorie wird zentral am Markt aus einer deterministischen Liste
ausgewählt. Eine Durchführung überschreibt diese Zuordnung nicht.
## 6. Markt- und Durchführungsdaten
### 6.1 Allgemeine Informationen
- Veranstalter
- Ort
- Gemeinde
- Kanton
- Adresse
- Kontaktperson
- E-Mail-Adresse
- Telefonnummer
- Notizen
Diese Angaben werden pro Durchführung erfasst. Marktname und Marktart/Kategorie
gehören ausschließlich zur zentralen Marktdefinition.
### 6.2 Termin und Durchführung
- Beginn und Ende des Marktes
- Datum jedes einzelnen Markttages
- Standzeiten pro Tag
- Auf- und Abbauzeiten
- Anmeldefrist
- Datum der Anfrage oder Anmeldung
- Datum der Bestätigung
- Datum der Durchführung
- Wochentag
- Saison
### 6.3 Stand und Infrastruktur
- Standgröße
- Tisch vorhanden
- Tisch kann gemietet werden
- Eigener Tisch erforderlich
- Zelt oder Überdachung vorhanden
- Strom vorhanden
- Wasser vorhanden
- Beleuchtung vorhanden
- Weitere Infrastruktur
- Anforderungen an den eigenen Stand
### 6.4 Anfahrt und Logistik
- Anfahrtsadresse
- Entfernung oder Fahrzeit
- Parkmöglichkeiten
- Zufahrt für Anlieferung
- Zeitfenster für die Anlieferung
- Möglichkeit zum direkten Ausladen
- Lager- oder Aufbewahrungsmöglichkeiten
- Besondere Hinweise zur Anfahrt
### 6.5 Kosten und Konditionen
- Standgebühr
- Gebühren für Tisch, Strom oder weitere Infrastruktur
- Weitere Pflichtkosten
- Gesamtkosten
- Zahlungsfrist
- Zahlungsart
- Abmeldebedingungen
- Umsatzbeteiligung, falls vorhanden
- Weitere Teilnahmebedingungen
### 6.6 Vertrag und Unterlagen
Die zugehörigen Unterlagen sollen über einen Link mit einem eigenen Ordner
verbunden werden können.
- Link zum Vertragsordner
Der Link wird pro Durchführung erfasst.
### 6.7 Deterministische Referenzwerte und Berechnungen
Deterministisch vorgegebene Referenzwerte; die jeweilige Auswahl wird auf der
zuständigen Ebene gespeichert:
- Marktart/Kategorie
- Marktstatus
- Zahlungsstatus
- Zahlungsart
- Kanton
- standardisierte Ja/Nein-Merkmale für Infrastrukturangaben
Abgeleitete Werte werden nicht manuell eingegeben:
- Wochentag aus dem Datum
- Saison aus dem Durchführungsdatum
- Gesamtkosten als manuell erfassbarer Bruttobetrag mit optionaler Freitext-Aufschlüsselung
- offener Betrag aus Kosten und bezahltem Betrag
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
Durchführungstagen
## 7. Statusverwaltung
Folgende Marktstatus sollen verfügbar sein:
- Offen
- Warteliste
- Angemeldet
- Bestätigung ausstehend
- Bestätigt
- Abgesagt
- Durchgeführt
Der initiale Status ist `Offen`. Die Statuswerte werden grundsätzlich manuell
gesetzt. `Durchgeführt` wird nach dem letzten Durchführungstag automatisch
gesetzt, sofern die Durchführung nicht abgesagt ist. Manuelle Korrektur bleibt
möglich.
## 8. Bezahlung
Der Zahlungsstatus wird unabhängig vom allgemeinen Marktstatus gespeichert.
Zahlungsstatus:
- Nicht bezahlt
- Bezahlt
Zusätzliche Zahlungsinformationen:
- Zahlungsdatum
- Bezahlter Betrag
- Offener Betrag
## 9. Erlöserfassung
Der Erlös wird nicht manuell erfasst, sondern aus den OTC-Verkäufen berechnet.
Bei mehrtägigen Märkten wird der Erlös je Durchführungstag separat ermittelt.
Pro Durchführungstag:
- Datum
- Bruttoerlös aus OTC-Verkäufen
- Anzahl verkaufter Artikel aus OTC-Positionen
- Besondere Ereignisse oder Beobachtungen
Automatisch zu berechnen:
- Gesamterlös des Marktes
- Gesamtzahl verkaufter Artikel
- Durchschnittlicher Erlös pro Tag
- Durchschnittlicher Erlös pro verkauftem Artikel, sofern berechenbar
Die Tageswerte sollen einzeln und als Gesamtergebnis auswertbar sein.
## 10. Marktübersicht
Die zentrale Übersicht soll ermöglichen:
- alle Märkte anzuzeigen
- nach Marktart zu filtern
- nach Status zu filtern
- nach Gemeinde oder Ort zu filtern
- nach Datum zu sortieren
- nach Erlös zu sortieren
- den Bezahlstatus zu erkennen
- kommende Märkte hervorzuheben
- überfällige Anmeldungen oder Zahlungen zu erkennen
- Märkte zu öffnen und zu bearbeiten
Mindestens sichtbar:
- Marktname
- Marktart
- Ort oder Gemeinde
- Datum
- Wochentag
- Status
- Gesamtkosten
- Bezahlstatus
- Bruttoerlös
## 11. Intelligente Auswertungen
Das System soll später Zusammenhänge, Unterschiede und mögliche Korrelationen
zwischen Marktmerkmalen und Ergebnissen sichtbar machen.
### 10.1 Marktart und Erlös
- Welche Marktarten erzielen den höchsten Erlös?
- Sind Weihnachtsmärkte erfolgreicher als Herbstmärkte?
- Welche Marktarten haben das beste Verhältnis zwischen Erlös und Kosten?
- Welche Marktarten führen zu den meisten verkauften Artikeln?
### 10.2 Datum, Saison und Erlös
- An welchen Daten werden die höchsten Erlöse erzielt?
- Gibt es Unterschiede zwischen frühem und spätem Saisonzeitpunkt?
- Wie entwickelt sich der Erlös innerhalb einer Saison?
- Welche Monate und Jahreszeiten sind besonders erfolgreich?
- Gibt es einen Zusammenhang zwischen Marktdatum und Verkaufsleistung?
### 10.3 Zahlungstermin und Markterfolg
- Wie viele Tage liegen zwischen Zahlungstermin und Marktdatum?
- Laufen Märkte besser, wenn der Zahlungstermin früh oder spät vor dem Markt
liegt?
- Gibt es einen Zusammenhang zwischen dem Zeitpunkt der Zahlung und dem
späteren Erlös?
- Gibt es Unterschiede zwischen Märkten vor und nach dem eigenen Zahlungstag?
### 10.4 Gemeinde und wirtschaftliches Umfeld
Der Ort soll später über die Gemeinde mit zusätzlichen wirtschaftlichen Daten
vergleichbar sein.
Zu prüfen ist insbesondere die Hypothese, dass Märkte in Gemeinden mit höherem
durchschnittlichem Einkommen möglicherweise einen höheren Erlös erzielen als
Märkte in Gemeinden mit tieferem Einkommen.
Auswertungen:
- Zusammenhang zwischen Gemeinde und Erlös
- Zusammenhang zwischen Gemeinde-Einkommen und Erlös
- Zusammenhang zwischen Kaufkraft und Anzahl verkaufter Artikel
- Vergleich verschiedener Gemeinden
- Vergleich ähnlicher Märkte in Gemeinden mit unterschiedlichem
Einkommensniveau
Die Quelle, Aktualisierung und fachliche Ownership der zusätzlichen
wirtschaftlichen Gemeindedaten sind noch nicht spezifiziert.
@@ -1,66 +0,0 @@
# Editable Action Card
## Zweck
`Editable Action Card` ist ein fachlich neutrales UI-Pattern fuer Karten mit
Titelaktionen, Lesemodus und optionalem Bearbeitungsmodus.
Das Pattern kapselt nur die technische Interaktion:
- Umschalten zwischen Lese- und Bearbeitungsansicht
- Sichtbarer, im Bearbeitungsmodus deaktivierter Bearbeiten-Button
- Abbrechen einer Bearbeitung
- optionale Loeschbestaetigung mit konfigurierbarem Token
- Deaktivieren der Loeschaktion bis zur gueltigen Bestaetigung
- gleichzeitiges Bearbeiten mehrerer Karten
- optionales Ein- und Ausblenden des Karteninhalts
Fachliche Aktionen, API-Aufrufe, Validierung und die Aktualisierung fachlicher
Daten bleiben beim owning Modul.
## Markup-Vertrag
Eine Karte wird mit `data-shared-editable-action-card` markiert. Die
Bedienelemente verwenden folgende Attribute:
- `data-shared-editable-card-edit`
- `data-shared-editable-card-cancel`
- `data-shared-editable-card-read`
- `data-shared-editable-card-form`
- optional `data-shared-editable-card-collapsible`
- optional `data-shared-editable-card-collapsed`
- optional `data-shared-editable-card-toggle`
- optional `data-shared-editable-card-delete`
- optional `data-shared-editable-card-delete-confirmation`
- optional `data-shared-editable-card-delete-input`
- optional `data-shared-editable-card-delete-submit`
- optional `data-shared-editable-card-delete-cancel`
Der Bearbeiten-Button bleibt im Titelsegment sichtbar. Im Bearbeitungsmodus
wird er deaktiviert (`disabled`, `aria-disabled="true"` und
`sg-button--inactive`); die Aktionen zum Abbrechen und Speichern bleiben aktiv.
Das Loesch-Token wird je Karte mit
`data-shared-editable-delete-token` konfiguriert. Die Standardaktion fuer das
Loeschen bleibt im owning Modul und verwendet den bestehenden fachlichen
Handler.
## Optionales Ein- und Ausblenden
Eine Karte mit `data-shared-editable-card-collapsible` kann ihren Inhalt
ein- und ausblenden. Mit `data-shared-editable-card-collapsed` wird der initial
eingeklappte Zustand markiert. Der Toggle verwendet die Beschriftungen
„Anzeigen“ und „Verbergen“. Beim Wechsel in den Bearbeitungsmodus wird der
Inhalt eingeblendet und der Toggle deaktiviert. Nach dem Bearbeiten bleibt der
aktuelle Sichtbarkeitszustand erhalten.
Die zentrale Komponente stellt dafür `setVisibility(card, visible)`,
`isVisible(card)` und `setToggleEnabled(card, enabled)` bereit. Fachmodule
verwenden diese Methoden nur für fachliche Folgeaktionen wie das erneute
Rendern nach dem Speichern.
## Nutzung im Lager
Im Lager-Drawer verwenden die aktive Charge und offene Chargen das Pattern.
Rein lesende Karten, insbesondere historische und virtuelle Chargen, bleiben
beim bestehenden Content-Card-Pattern.
@@ -1,27 +0,0 @@
# Mobile Detail
## Zweck
`SharedMobileDetail` ist ein fachlich neutrales Frontend-Pattern fuer mobile
Listen-/Detailansichten. Es ersetzt auf kleinen Viewports den sichtbaren
Drawer durch eine vollflaechige Detailansicht.
## Verantwortung
Das Pattern kapselt ausschliesslich technische Interaktion:
- Verschieben des vorhandenen Detail-DOMs in einen mobilen Host
- Anzeigen und Entfernen des mobilen Hosts
- Browser-History und `popstate`-kompatibler Zurueck-Navigation
- Zuruecksetzen des Body-Locks und des globalen Drawer-Zustands
- Zuruecksetzen der Drawer-Attribute beim Verlassen der Detailansicht
Fachliche Darstellung, API-Aufrufe, Validierung und Speichern bleiben im
owning Modul.
## Nutzung
Fachliche Bereiche erzeugen einen Controller mit eigenem Host-/Drawer-
Attribut, eigener Body-Klasse und einem bestehenden Styleguide-
Navigationskarten-Markup fuer die Zurueck-Navigation. Der Controller darf
keine fachlichen Daten oder fachlichen Zustandsentscheidungen enthalten.
+1 -1
View File
@@ -1 +1 @@
2026.05.18.2
2026.05.18.1
+16 -20
View File
@@ -77,10 +77,8 @@
<p class="sg-preview-label">Component: Large Table</p>
<p>Suche, Sortierung und „Mehr laden“ sind hier Demo-Markup. Produktions-Implementierungen müssen Treffer serverseitig neu rendern.</p>
<p>Optionale Spaltenbreiten-Anpassung: <code>data-column-resize="true"</code> aktiviert die Funktion. Die Tabelle benötigt dafür eine stabile, portalweit eindeutige <code>data-column-resize-key</code>. Die Header-Zellen können mit <code>data-default-width</code> (Prozent, Pixel oder relative <code>fr</code>-Werte) und die Tabelle mit <code>data-column-min-width</code> konfiguriert werden. Die Runtime wird nach dynamischem Nachladen über <code>window.sgInitLargeTableColumnResize({ root })</code> erneut aufgerufen. Gespeicherte Werte liegen als <code>{ version: 3, ratios: number[] }</code> unter <code>sg:large-table:column-widths:&lt;key&gt;</code> in <code>localStorage</code>; die Werte sind proportionale Anteile der verfügbaren Tabellenbreite.</p>
<article id="styleguide-large-table" class="sg-card sg-large-table" data-component="large-table" data-column-resize="true" data-column-resize-key="styleguide-large-table" data-column-min-width="8rem" role="table" aria-label="Beispiel Large Table">
<div class="sg-large-table__viewport" data-component-part="large-table-viewport">
<article class="sg-card sg-large-table" data-component="large-table" role="table" aria-label="Beispiel Large Table">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header">
<div class="sg-strong">Large Table</div>
<span class="sg-search-field-row" data-component="search-field">
@@ -98,35 +96,31 @@
</div>
<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--header" role="row" data-component-part="large-table-header-row">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="0" data-default-width="20%">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="0">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 1 sortieren">
<span class="sg-large-table__sort-label">Spalte 1</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
</button>
<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von Spalte 1 anpassen" data-large-table-resize-handle="0"></span>
</div>
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="1" data-default-width="20%">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="1">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 2 sortieren">
<span class="sg-large-table__sort-label">Spalte 2</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
</button>
<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von Spalte 2 anpassen" data-large-table-resize-handle="1"></span>
</div>
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="2" data-default-width="20%">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="2">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 3 sortieren">
<span class="sg-large-table__sort-label">Spalte 3</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
</button>
<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von Spalte 3 anpassen" data-large-table-resize-handle="2"></span>
</div>
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="3" data-default-width="20%">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="3">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 4 sortieren">
<span class="sg-large-table__sort-label">Spalte 4</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
</button>
<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von Spalte 4 anpassen" data-large-table-resize-handle="3"></span>
</div>
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="4" data-default-width="20%">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="4">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 5 sortieren">
<span class="sg-large-table__sort-label">Spalte 5</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
@@ -216,20 +210,22 @@
<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--load-more" role="row" data-component-part="large-table-row" data-large-table-load-more-row="true">
<div class="sg-large-table__cell sg-large-table__cell--load-more" role="cell">
<article class="sg-card sg-large-table__load-more-card" data-component="card" data-pattern="navigation-card" aria-label="Navigations-Card">
<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-pattern-part="navigation-card-segment">
<div class="sg-navigation-card-center">
<a class="sg-hyperlink" href="#" data-component="hyperlink" data-large-table-load-more-trigger="true">Mehr laden</a>
</div>
<div class="sg-navigation-card-layout sg-large-table__load-more-layout">
<div class="sg-navigation-card-block">
<article class="sg-card" data-component="card" data-pattern="navigation-card" aria-label="Navigations-Card">
<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-pattern-part="navigation-card-segment">
<div class="sg-navigation-card-center">
<a class="sg-hyperlink" href="#" data-component="hyperlink" data-large-table-load-more-trigger="true">Mehr laden</a>
</div>
</div>
</article>
</div>
</article>
</div>
</div>
</div>
</div>
</article>
</section>
<script src="../scripts/large-table-column-resize.js"></script>
</body>
</html>
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Komponente E-Mail Footer</title>
<link rel="stylesheet" href="../styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Komponente – E-Mail Footer</h1>
<section id="component-email-footer">
<p class="sg-preview-label">Komponente: E-Mail Footer</p>
<p class="sg-preview-label">E-Mail Footer not subscribed</p>
<div class="sg-preview-area">
<article
class="sg-card sg-card--content-card sg-card--footer-card"
data-component="content-card"
data-component-context="email-footer"
data-component-variant="not-subscribed"
aria-label="E-Mail Footer not subscribed"
>
<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body">
<div class="sg-text-layout-pattern__sample sg-text-layout-pattern__two-column" data-pattern-part="text-block-two-column">
<p class="sg-body sg-text-layout-pattern__column">
Footer links: Platzhaltertext fuer allgemeine Hinweise, Navigation oder Kontaktinformationen im zweispaltigen Layout.
</p>
<p class="sg-body sg-text-layout-pattern__column">
Footer rechts: Platzhaltertext fuer ergaenzende Angaben, rechtliche Hinweise oder sekundäre Footer-Inhalte im gleichen Raster.
</p>
</div>
</div>
</article>
</div>
</section>
</body>
</html>
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Komponente E-Mail Header</title>
<link rel="stylesheet" href="../styleguide.css">
<link rel="stylesheet" href="../email-styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Komponente – E-Mail Header</h1>
<section id="component-email-header">
<p class="sg-preview-label">Komponente: E-Mail Header</p>
<div class="sg-preview-area">
<div class="sg-email-preview-viewport">
<div class="sg-email-preview-body">
<header
class="sg-email-header"
aria-label="E-Mail Header"
data-component="email-header"
data-component-variant="logo-only"
data-derived-from="portal-header-without-options-row"
>
<div class="sg-email-header__main" data-component-part="main">
<p class="sg-email-header__brand" data-component-part="brand">ValueStockFinder</p>
</div>
</header>
</div>
</div>
</div>
</section>
</body>
</html>
@@ -26,10 +26,6 @@
<button class="sg-interaction-element sg-button sg-button--active" type="button" data-component="button" data-component-state="active">
Link-Button aktiv
</button>
<button class="sg-interaction-element sg-button sg-button--warning" type="button" data-component="button" data-component-variant="warning">
Link Button Warning
</button>
</div>
</section>
@@ -836,21 +832,6 @@
</span>
</section>
<!-- Component: Tooltip -->
<section id="component-tooltip">
<p class="sg-preview-label">Component: Tooltip</p>
<span class="sg-tooltip-wrap" data-component="tooltip">
<button class="sg-interaction-element sg-button sg-button--active" type="button" aria-describedby="component-tooltip-panel" aria-expanded="false" data-component-part="tooltip-trigger">
Tooltip anzeigen
</button>
<span class="sg-tooltip-panel sg-table-label" id="component-tooltip-panel" role="tooltip" hidden data-component-part="tooltip-panel">
Diese Sprechblase ist trigger-verankert und wechselt bei fehlendem Platz automatisch die Seite.
</span>
</span>
</section>
<!-- Component: Hyperlink -->
<section id="component-hyperlink">
@@ -862,29 +843,7 @@
</section>
<script src="../scripts/help-icon-overlays.js"></script>
<script src="../scripts/tooltip-overlays.js"></script>
<section id="component-file-input">
<p class="sg-preview-label">Component: File Input</p>
<div class="sg-form-preview-area">
<div class="sg-file-input" data-component="file-input">
<div class="sg-file-input__row sg-labeled-input-row">
<label class="sg-label" for="component-file-input-control">Datei auswählen</label>
<input class="sg-file-input__control" id="component-file-input-control" type="file" multiple>
<button class="sg-interaction-element sg-button sg-button--active sg-file-input__trigger" type="button">Dateien auswählen</button>
</div>
</div>
</div>
</section>
<script>
document.querySelectorAll('[data-component="file-input"]').forEach((fileInput) => {
const input = fileInput.querySelector('.sg-file-input__control');
const trigger = fileInput.querySelector('.sg-file-input__trigger');
if (input && trigger) {
trigger.addEventListener('click', () => input.click());
}
});
document.querySelectorAll('.sg-mode-toggle:not(.sg-activation-mode-toggle)').forEach((toggle) => {
toggle.addEventListener('click', () => {
const nextState = toggle.dataset.active === 'relative' ? 'absolute' : 'relative';
+1 -19
View File
@@ -7,7 +7,6 @@ Der Styleguide bleibt in diesem Repository die Source of Truth. Der Sync verteil
- den deploy-relevanten CSS-Upstream versioniert als `public/assets/styleguide.upstream.css`
- die vollstaendige Styleguide-Dokumentation gespiegelt nach `docs/styleguide`
- die portal-spezifische Fertig-CSS-Datei als `public/assets/styles.css`
- die konsumierbare Large-Table-Runtime als `public/scripts/large-table-column-resize.js`
Zielrepos:
@@ -32,10 +31,8 @@ Beispiel:
- `public/assets/styleguide.upstream.css` aktualisiert
- `public/assets/styleguide.upstream.meta.json` aktualisiert (Version, Commit, Zeitstempel)
- `docs/styleguide` gespiegelt (mit `--delete`, ohne `.git`, `.codex`, `AGENTS.md` und interne `scripts/`)
- `public/scripts/large-table-column-resize.js` aus der Styleguide-Source-of-Truth kopiert
- `docs/styleguide` gespiegelt (mit `--delete`, ohne `.git`, `.codex`, `AGENTS.md`, `scripts/`)
- `public/assets/styles.css` aktualisiert
- `public/scripts/large-table-column-resize.js` aktualisiert; lokale Portal-Kopien sind nicht zulässig
- Optional: automatischer Commit + Push im Portalrepo
## Standardprozess je Release
@@ -45,18 +42,3 @@ Beispiel:
3. Styleguide commit + push
4. Sync-Skript ausfuehren
5. Beide Portalrepos Smoke-Testen
## Large-Table-Runtime
Die Runtime liegt ausschließlich im Styleguide unter `scripts/large-table-column-resize.js`.
Der Sync kopiert nur diese explizit freigegebene Runtime nach `public/scripts/`; andere
Styleguide-Scripts wie Prüf- und Sync-Shellscripts werden nicht in `docs/styleguide/scripts/`
gespiegelt. Dadurch bleibt der Styleguide die Source of Truth und ein erneuter Sync
überschreibt die Portaldatei kontrolliert.
Für den Smoke-Test in beiden Portalen ist zu prüfen:
- `public/scripts/large-table-column-resize.js` ist vorhanden und unter `/scripts/large-table-column-resize.js` ladbar.
- Die Large-Table-Seite lädt die Runtime ohne lokale Inline- oder Duplikatimplementierung.
- Nach einer Spaltenbreitenänderung wird der Eintrag unter `sg:large-table:column-widths:<key>` gespeichert.
- Ein erneuter Sync ersetzt die Datei aus der Styleguide-Quelle.
+4 -19
View File
@@ -31,7 +31,6 @@
<tr><td>white</td><td>#FFFFFF</td><td>Standardfläche von Buttons, Eingabefeldern, Pulldown-Triggern, Checkboxen, Radios, Toggle-Tracks und Card-Trennern.</td></tr>
<tr><td>signal-green</td><td>#009101</td><td>Positive Score-Balken und positive Chart-Werte.</td></tr>
<tr><td>signal-green-light</td><td>#65A665</td><td>Hellgrüne Variante für positive Signal- und Notification-Flächen.</td></tr>
<tr><td>signal-green-red</td><td>#C0624D</td><td>Rotgrüne Variante für positive oder negative Bewertungsdarstellungen in Notifications.</td></tr>
<tr><td>signal-yellow</td><td>#9C7A00</td><td>Neutrale Score-Balken, neutrale Chart-Werte und Warnwerte in Tabellen.</td></tr>
<tr><td>ampel-hellgelb</td><td>#D3B962</td><td>Untersegmente in der Pattern-Notification-Variante der Object Card.</td></tr>
<tr><td>signal-red</td><td>#9B3B2F</td><td>Negative Score-Balken und negative Chart-Werte.</td></tr>
@@ -39,29 +38,13 @@
<tr><td>font-light</td><td>#FFFFFF</td><td>Schrift auf dunklen Flächen, Portalheader, Card-Headern, Prozessbuttons und ausgewählten Tab-Buttons.</td></tr>
<tr><td>font-hyperlink</td><td>#FF6900</td><td>Links in Texten, Prozessbuttons und Page-Navigations-Cards.</td></tr>
<tr><td>process-inactive</td><td>#FFAE79</td><td>Inaktive Hintergrundfläche des Prozessbuttons.</td></tr>
<tr><td>background-purple</td><td>#373C4A</td><td>Globaler Portalhintergrund.</td></tr>
<tr><td>background-purple</td><td>#373C4A</td><td>Globaler Portal- und E-Mail-Hintergrund.</td></tr>
<tr><td>background-purple-light</td><td>#656C7D</td><td>Group Cards und gruppierende Card-Flächen.</td></tr>
<tr><td>transparent</td><td>transparent</td><td>Transparenter Foundation-Wert für semantische Overlay-/Box-Varianten ohne Eigenfläche.</td></tr>
</tbody>
</table>
</section>
<section id="foundations-card-text">
<h2 class="sg-sub-heading sg-text-on-dark">Card Semantic Tokens</h2>
<table class="sg-foundation-table sg-table-label">
<thead>
<tr>
<th>Name</th>
<th>Wert</th>
<th>Verwendung</th>
</tr>
</thead>
<tbody>
<tr><td>text-card-signal</td><td>font-light</td><td>Weiße Schrift auf allen farbigen Signal-Segmenten, unabhängig vom übergeordneten Card-Kontext.</td></tr>
</tbody>
</table>
</section>
<section id="foundations-states">
<h2 class="sg-sub-heading sg-text-on-dark">States</h2>
<table class="sg-foundation-table sg-table-label">
@@ -173,6 +156,7 @@
<tr><td>input-label-width</td><td>9rem</td><td>Desktop-Breite der Label-Spalte für gruppierte Formularzeilen mit Pulldowns, Slidern, Radio-Feldern, Checkbox-Feldern sowie ein- und mehrzeiligen Eingabefeldern.</td></tr>
<tr><td>input-field-desktop-width</td><td>400px</td><td>Fixe Desktop-Breite von ein- und mehrzeiligen Eingabefeldern in der Input-Component-Preview.</td></tr>
<tr><td>input-field-max-width</td><td>600px</td><td>Maximale Breite von ein- und mehrzeiligen Eingabefeldern (ohne Suchfeld).</td></tr>
<tr><td>email-body-max-width</td><td>600px</td><td>Maximale Breite des Inhaltsbereichs von E-Mail-Templates innerhalb des E-Mail-Viewports.</td></tr>
<tr><td>object-card-height</td><td>600px</td><td>Fixe Höhe der Object Card im Desktop-Layout (u. a. für Company Card).</td></tr>
<tr><td>scrollable-card-width</td><td>600px</td><td>Standardbreite der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
<tr><td>scrollable-card-height</td><td>600px</td><td>Standardhöhe der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
@@ -192,7 +176,8 @@
<tr><td>notifications-card-min-width</td><td>445px</td><td>Mindestbreite der Notification Card im Pattern Notifications; 50px größer als die Object Card und Grundlage für Flex-Basis und Mindestbreite des Patterns.</td></tr>
<tr><td>search-field-width</td><td>15.3rem</td><td>Fixe Breite des Suchfeld-Inputs inklusive Clear-Button-Bereich.</td></tr>
<tr><td>layer-pulldown-panel</td><td>40</td><td>Layer-Stufe für geöffnete Pulldown-Ausklappfelder, damit sie über anderen Inhalten liegen.</td></tr>
<tr><td>multiselect-pulldown-panel-desktop-width</td><td>500px</td><td>Foundation-Breite des Multiselektions-Pulldown-Panels; wird als feste Pattern-Breite mit Viewport-Kappe wiederverwendet.</td></tr>
<tr><td>multiselect-pulldown-panel-desktop-width</td><td>500px</td><td>Reservierte Foundation-Breite für Multiselektions-Pulldown-Panels; aktuell nicht aktiv genutzt, da das Panel inhaltsbasiert mit Viewport-Kappe skaliert.</td></tr>
<tr><td>multiselect-pulldown-panel-mobile-width</td><td>80vw</td><td>Reservierte mobile Foundation-Breite für Multiselektions-Pulldown-Panels; aktuell nicht aktiv genutzt, da das Panel inhaltsbasiert mit Viewport-Kappe skaliert.</td></tr>
<tr><td>options-row-mode-toggle-width</td><td>7rem</td><td>Breite des Modus-Schiebers in der Options Row.</td></tr>
<tr><td>options-row-help-panel-width</td><td>16rem</td><td>Breite des Help-Panels in der Options Row.</td></tr>
<tr><td>card-list-drawer-width</td><td>40%</td><td>Relative Breite des ausziehbaren Card-Listenbereichs.</td></tr>
-2
View File
@@ -48,11 +48,9 @@
<li><a href="./patterns/navigation-card.html">Navigation Card</a></li>
<li><a href="./patterns/left-navigation.html">Left Navigation</a></li>
<li><a href="./patterns/notifications.html">Notifications</a></li>
<li><a href="./patterns/history-graph.html">History Graph</a></li>
<li><a href="./patterns/content-cards-group.html">Content Cards Group</a></li>
<li><a href="./patterns/card-gruppe-mit-tastennavigation.html">Card Gruppe mit Tastennavigation</a></li>
<li><a href="./patterns/formular-mit-abschnitten.html">Formular mit Abschnitten</a></li>
<li><a href="./patterns/file-upload.html">Datei-Upload</a></li>
<li><a href="./patterns/multiselektions-pulldown.html">Multiselektions-Pulldown</a></li>
<li><a href="./patterns/text-layouts.html">Text Layouts</a></li>
<li><a href="./patterns/delete-confirmation-overlay.html">Overlay Card</a></li>
-71
View File
@@ -1,71 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Pattern Datei-Upload</title>
<link rel="stylesheet" href="../styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Pattern – Datei-Upload</h1>
<section id="pattern-file-upload">
<p class="sg-preview-label">Pattern: Datei-Upload</p>
<form class="sg-file-upload" data-pattern="file-upload" action="#" method="post">
<div class="sg-file-upload__dropzone sg-labeled-input-row" data-drag-over="false">
<label class="sg-label" for="file-upload-input">Datei auswählen</label>
<input class="sg-file-input__control" id="file-upload-input" name="documents" type="file" multiple aria-describedby="file-upload-status">
<button class="sg-interaction-element sg-button sg-button--active sg-file-upload__trigger" type="button">Dateien auswählen</button>
</div>
<p class="sg-body sg-file-upload__status" id="file-upload-status" aria-live="polite">Noch keine Dateien ausgewählt.</p>
<ul class="sg-file-upload__files" aria-label="Ausgewählte Dateien"></ul>
<button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive sg-file-upload__submit" type="submit" disabled aria-disabled="true">Hochladen</button>
</form>
</section>
<script>
(() => {
const upload = document.querySelector('[data-pattern="file-upload"]');
if (!upload) return;
const input = upload.querySelector('#file-upload-input');
const dropzone = upload.querySelector('.sg-file-upload__dropzone');
const trigger = upload.querySelector('.sg-file-upload__trigger');
const filesList = upload.querySelector('.sg-file-upload__files');
const status = upload.querySelector('.sg-file-upload__status');
const submit = upload.querySelector('[type="submit"]');
let selectedFiles = [];
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
const render = () => {
filesList.innerHTML = '';
selectedFiles.forEach((file, index) => {
const item = document.createElement('li');
item.className = 'sg-file-upload__file';
const name = document.createElement('span');
name.className = 'sg-file-upload__file-name';
name.textContent = file.name;
const meta = document.createElement('span');
meta.className = 'sg-file-upload__file-meta';
meta.textContent = ` · ${formatSize(file.size)}`;
name.append(meta);
const remove = document.createElement('button');
remove.className = 'sg-interaction-element sg-button sg-button--active sg-file-upload__remove';
remove.type = 'button';
remove.textContent = 'Entfernen';
remove.addEventListener('click', () => { selectedFiles.splice(index, 1); render(); });
item.append(name, remove);
filesList.append(item);
});
const count = selectedFiles.length;
status.textContent = count === 0 ? 'Noch keine Dateien ausgewählt.' : `${count} Datei${count === 1 ? '' : 'en'} ausgewählt.`;
submit.disabled = count === 0;
submit.setAttribute('aria-disabled', String(count === 0));
submit.classList.toggle('sg-button--process-inactive', count === 0);
};
trigger.addEventListener('click', () => input.click());
input.addEventListener('change', () => { selectedFiles = [...selectedFiles, ...Array.from(input.files)]; input.value = ''; render(); });
['dragenter', 'dragover'].forEach((eventName) => dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.dataset.dragOver = 'true'; }));
['dragleave', 'drop'].forEach((eventName) => dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.dataset.dragOver = 'false'; }));
dropzone.addEventListener('drop', (event) => { selectedFiles = [...selectedFiles, ...Array.from(event.dataTransfer.files)]; render(); });
render();
})();
</script>
</body>
</html>
-120
View File
@@ -1,120 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Pattern History Graph</title>
<link rel="stylesheet" href="../styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Pattern – History Graph</h1>
<section id="pattern-history-graph">
<p class="sg-preview-label">Pattern: History Graph</p>
<div class="sg-preview-area sg-history-graph" aria-label="Verlauf von Ereignissen">
<ol class="sg-history-graph__events">
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Aktuellstes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green" data-component-part="card-header">
<p class="sg-body">Aktuellstes Ereignis mit einem einzelnen farbigen Segment.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Zweites Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-yellow" data-component-part="card-header">
<p class="sg-body">Ereignis mit mehreren unabhängig gefärbten Segmenten.</p>
</div>
<div class="sg-card-segment sg-card-segment--body sg-card-segment--white" data-component-part="card-body">
<p class="sg-body">Der Inhalt, zum Beispiel Datum und Beschreibung, wird individuell durch das konsumierende Portal definiert.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Ältestes dargestelltes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-red" data-component-part="card-header">
<p class="sg-body">Ältestes dargestelltes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Viertes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green-light" data-component-part="card-header">
<p class="sg-body">Viertes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Fünftes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-yellow" data-component-part="card-header">
<p class="sg-body">Fünftes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Sechstes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green" data-component-part="card-header">
<p class="sg-body">Sechstes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Siebtes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-red" data-component-part="card-header">
<p class="sg-body">Siebtes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Achtes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green-light" data-component-part="card-header">
<p class="sg-body">Achtes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Neuntes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-yellow" data-component-part="card-header">
<p class="sg-body">Neuntes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Zehntes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green" data-component-part="card-header">
<p class="sg-body">Zehntes Ereignis.</p>
</div>
</article>
</li>
</ol>
<!-- Wird nur gerendert, wenn weitere Ereignisse geladen werden können. -->
<div class="sg-history-graph__navigation">
<div class="sg-navigation-card-layout">
<div class="sg-navigation-card-block">
<article class="sg-card" data-component="card" data-pattern="navigation-card" aria-label="Weitere Ereignisse laden">
<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-pattern-part="navigation-card-segment">
<div class="sg-navigation-card-center">
<a class="sg-hyperlink" href="#" data-component="hyperlink">Mehr laden</a>
</div>
</div>
</article>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
@@ -224,16 +224,14 @@
</div>
<div class="sg-pulldown-panel__row sg-pulldown-panel__row--disabled" data-pulldown-filter-row data-active="false" data-component-part="pulldown-filter-row">
<div class="sg-pulldown-panel__row-header">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 1: aus">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 1: aus">
<span class="sg-mode-toggle__switch" aria-hidden="true">
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--left">aus</span>
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--right">an</span>
<span class="sg-mode-toggle__handle"></span>
</span>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 1</p>
</div>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 1</p>
<select class="sg-interaction-element sg-pulldown" aria-label="Pulldown 1 Auswahl" disabled>
<option selected>Option 1</option>
@@ -247,16 +245,14 @@
<h2 id="multiselect-block-4" class="sg-strong sg-form-sections-card__chapter-title">Block 4</h2>
<div class="sg-pulldown-panel__row sg-pulldown-panel__row--disabled" data-pulldown-filter-row data-active="false" data-component-part="pulldown-filter-row">
<div class="sg-pulldown-panel__row-header">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 2: aus">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 2: aus">
<span class="sg-mode-toggle__switch" aria-hidden="true">
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--left">aus</span>
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--right">an</span>
<span class="sg-mode-toggle__handle"></span>
</span>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 2</p>
</div>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 2</p>
<select class="sg-interaction-element sg-pulldown" aria-label="Pulldown 2 Auswahl" disabled>
<option selected>Option 1</option>
@@ -266,16 +262,14 @@
</div>
<div class="sg-pulldown-panel__row sg-pulldown-panel__row--disabled" data-pulldown-filter-row data-active="false" data-component-part="pulldown-filter-row">
<div class="sg-pulldown-panel__row-header">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 3: aus">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 3: aus">
<span class="sg-mode-toggle__switch" aria-hidden="true">
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--left">aus</span>
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--right">an</span>
<span class="sg-mode-toggle__handle"></span>
</span>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 3</p>
</div>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 3</p>
<select class="sg-interaction-element sg-pulldown" aria-label="Pulldown 3 Auswahl" disabled>
<option selected>Option 1</option>
@@ -285,16 +279,14 @@
</div>
<div class="sg-pulldown-panel__row sg-pulldown-panel__row--disabled" data-pulldown-filter-row data-active="false" data-component-part="pulldown-filter-row">
<div class="sg-pulldown-panel__row-header">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 4: aus">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 4: aus">
<span class="sg-mode-toggle__switch" aria-hidden="true">
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--left">aus</span>
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--right">an</span>
<span class="sg-mode-toggle__handle"></span>
</span>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 4</p>
</div>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 4</p>
<select class="sg-interaction-element sg-pulldown" aria-label="Pulldown 4 Auswahl" disabled>
<option selected>Option 1</option>
@@ -398,6 +390,28 @@
);
};
const updateMultiselectLabelAlignment = () => {
document.querySelectorAll('.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel').forEach((panel) => {
const labels = panel.querySelectorAll(
'.sg-slider-row[data-activatable="true"] > .sg-label, .sg-radio-activatable-group > .sg-label, [data-pulldown-filter-row] > .sg-pulldown-panel__label'
);
let maxWidth = 0;
labels.forEach((label) => {
const width = Math.ceil(label.scrollWidth || label.getBoundingClientRect().width);
if (width > maxWidth) {
maxWidth = width;
}
});
if (maxWidth > 0) {
panel.style.setProperty('--sg-multiselect-label-column-width', `${maxWidth}px`);
} else {
panel.style.removeProperty('--sg-multiselect-label-column-width');
}
});
};
document.querySelectorAll('.sg-slider-row').forEach((row) => {
const slider = row.querySelector('.sg-slider');
const valueOutput = row.querySelector('.sg-slider-value');
@@ -486,6 +500,9 @@
});
});
updateMultiselectLabelAlignment();
window.addEventListener('resize', updateMultiselectLabelAlignment);
document.querySelectorAll('.sg-pulldown-demo').forEach((demo) => {
const trigger = demo.querySelector('.sg-pulldown-demo__trigger');
@@ -510,12 +527,38 @@
}
});
demo.dataset.align = 'left';
demo.dataset.open = String(nextState);
trigger.setAttribute('aria-expanded', String(nextState));
if (!nextState) {
return;
}
updateMultiselectLabelAlignment();
const panel = demo.querySelector('.sg-pulldown-panel');
if (!panel) {
return;
}
demo.dataset.align = 'left';
panel.style.left = '0px';
panel.style.right = 'auto';
const initialRect = panel.getBoundingClientRect();
let offsetX = 0;
const maxRight = window.innerWidth;
if (initialRect.right > maxRight) {
offsetX -= initialRect.right - maxRight;
}
if (initialRect.left + offsetX < 0) {
offsetX += 0 - (initialRect.left + offsetX);
}
panel.style.left = `${offsetX}px`;
});
});
@@ -142,27 +142,6 @@
</div>
</article>
<p class="sg-preview-label">Pattern: Notification with title - green-red rating</p>
<article class="sg-card sg-card--notification-signal-green-red sg-notifications-pattern__card" data-pattern="notification-with-title" data-pattern-variant="green-red-rating" data-component="notification-card">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green-red sg-notifications-pattern__text-segment sg-notifications-pattern__title-segment" data-component-part="card-header">
<p class="sg-body">
<span class="sg-strong">Bewertung</span>
</p>
</div>
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green-red sg-notifications-pattern__text-segment" data-component-part="card-header">
<p class="sg-body">
Positive oder negative Bewertungsdarstellung zur Illustration.
</p>
</div>
<div class="sg-card-segment sg-card-segment--body sg-card-segment--signal-green-red" data-component-part="card-body">
<button class="sg-interaction-element sg-button sg-button--active" type="button" data-component="button" data-component-state="active">
zur Bewertung
</button>
</div>
</article>
<p class="sg-preview-label">Pattern: Notification with title - yellow</p>
<article class="sg-card sg-notifications-pattern__card" data-pattern="notification-with-title" data-pattern-variant="yellow" data-component="notification-card">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-yellow sg-notifications-pattern__text-segment sg-notifications-pattern__title-segment" data-component-part="card-header">
+1 -48
View File
@@ -17,7 +17,7 @@
</p>
<p class="sg-table-label" style="color: var(--text-options-row-description);">
Mobile: Pulldowns stehen mit maximal zwei Elementen pro Zeile; bei ungerader Anzahl steht der letzte Pulldown allein in einer 100-%-Zeile. Ein Suchfeld steht immer allein in einer 100-%-Zeile.
Mobile: rechte Optionen werden zum oberen Segment, linke Optionen zum unteren Segment; linke Optionen brechen innerhalb der verfügbaren Breite um.
</p>
<div class="sg-options-row" aria-label="Optionszeile" data-pattern="options-row">
@@ -83,20 +83,6 @@
</div>
</div>
<div class="sg-pulldown-demo" data-open="false" data-align="left" data-selection-mode="single" data-component="pulldown" data-component-context="options-row" data-component-state="inactive-selectable">
<button class="sg-interaction-element sg-pulldown sg-pulldown-demo__trigger" type="button" aria-expanded="false" aria-label="Very long English label for market selection" data-component-part="pulldown-trigger" data-label-base="Very long English market selection label">
Very long English market selection label
</button>
<div class="sg-pulldown-panel" aria-label="Opened market selection" data-component-part="pulldown-panel">
<ul class="sg-pulldown-option-list" aria-label="Market options">
<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option>
<span>United Kingdom</span>
</li>
</ul>
</div>
</div>
<div class="sg-search-field-row">
<span class="sg-input-single-line-wrap sg-search-field-input" data-has-value="false" data-component="single-line-input" data-component-context="options-row" data-component-state="inactive-selectable">
<input
@@ -134,39 +120,6 @@
</div>
</section>
<section id="pattern-options-row-mobile-cases">
<p class="sg-preview-label">Pattern: Options Row – Mobile-Fälle</p>
<p class="sg-table-label" style="color: var(--text-options-row-description);">
Prüffälle: kein Pulldown, ein Pulldown, Suchfeld ohne Ergebniszähler sowie lange deutsche Beschriftung.
</p>
<div class="sg-options-row sg-options-row--left-only" aria-label="Optionszeile ohne Pulldown" data-pattern="options-row-mobile-no-pulldown">
<div class="sg-options-row__left" data-pattern-part="options-row-primary-actions">
<div class="sg-search-field-row">
<span class="sg-input-single-line-wrap sg-search-field-input" data-has-value="false" data-component="single-line-input" data-component-context="options-row" data-component-state="inactive-selectable">
<input class="sg-interaction-element sg-input-single-line" type="text" placeholder="Suche ohne Ergebniszähler" aria-label="Suche ohne Ergebniszähler">
<button class="sg-input-clear-button" type="button" aria-label="Eingabe löschen">×</button>
</span>
</div>
</div>
</div>
<div class="sg-options-row sg-options-row--left-only" aria-label="Optionszeile mit einem Pulldown" data-pattern="options-row-mobile-one-pulldown">
<div class="sg-options-row__left" data-pattern-part="options-row-primary-actions">
<div class="sg-pulldown-demo" data-open="false" data-align="left" data-selection-mode="single" data-component="pulldown" data-component-context="options-row" data-component-state="inactive-selectable">
<button class="sg-interaction-element sg-pulldown sg-pulldown-demo__trigger" type="button" aria-expanded="false" aria-label="Sehr lange deutsche Beschriftung für eine Auswahl" data-component-part="pulldown-trigger" data-label-base="Sehr lange deutsche Beschriftung für eine Auswahl">
Sehr lange deutsche Beschriftung für eine Auswahl
</button>
<div class="sg-pulldown-panel" aria-label="Geöffnete Auswahl" data-component-part="pulldown-panel">
<ul class="sg-pulldown-option-list" aria-label="Auswahloptionen">
<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Option</span></li>
</ul>
</div>
</div>
</div>
</div>
</section>
<section id="pattern-options-row-left-only">
<p class="sg-preview-label">Pattern: Options row left only</p>
@@ -1,242 +0,0 @@
(function initLargeTableColumnResizeModule() {
const STORAGE_PREFIX = 'sg:large-table:column-widths:';
const STORAGE_VERSION = 1;
const DEFAULT_MIN_WIDTH = 128;
const KEYBOARD_STEP = 8;
const KEYBOARD_LARGE_STEP = 40;
let activeResize = null;
const getTables = (root) => root.querySelectorAll('[data-component="large-table"][data-column-resize="true"]');
const getColumnHeaders = (table) => Array.from(
table.querySelectorAll('[data-component-part="large-table-header-row"] [role="columnheader"]'),
);
const getMinWidth = (table) => {
const configured = table.dataset.columnMinWidth;
const value = configured || getComputedStyle(table).getPropertyValue('--large-table-column-min-width').trim();
if (!value) {
return DEFAULT_MIN_WIDTH;
}
const probe = document.createElement('span');
probe.style.cssText = `position:absolute;width:${value};visibility:hidden;`;
table.appendChild(probe);
const width = probe.getBoundingClientRect().width;
probe.remove();
return width > 0 ? width : DEFAULT_MIN_WIDTH;
};
const parseDefaultWidth = (rawValue, tableWidth) => {
if (!rawValue) {
return null;
}
const value = Number.parseFloat(rawValue);
if (!Number.isFinite(value) || value <= 0) {
return null;
}
if (rawValue.trim().endsWith('%')) {
return tableWidth * value / 100;
}
if (rawValue.trim().endsWith('fr')) {
return value;
}
return value;
};
const normalizeWidths = (widths, tableWidth, minWidth) => {
const safeWidths = widths.map((width) => Math.max(minWidth, width));
const total = safeWidths.reduce((sum, width) => sum + width, 0);
if (total <= 0) {
return Array(widths.length).fill(Math.max(minWidth, tableWidth / widths.length));
}
if (widths.some((width) => width !== width)) {
return Array(widths.length).fill(Math.max(minWidth, tableWidth / widths.length));
}
return safeWidths.map((width) => width * tableWidth / total);
};
const getDefaultWidths = (table, headers, minWidth) => {
const tableWidth = Math.max(table.clientWidth, minWidth * headers.length);
const configured = headers.map((header) => parseDefaultWidth(header.dataset.defaultWidth, tableWidth));
if (configured.some((width) => width === null)) {
return Array(headers.length).fill(Math.max(minWidth, tableWidth / headers.length));
}
return normalizeWidths(configured, tableWidth, minWidth);
};
const getStorageKey = (table) => `${STORAGE_PREFIX}${table.dataset.columnResizeKey || ''}`;
const readStoredWidths = (table, count, minWidth) => {
const key = getStorageKey(table);
if (!table.dataset.columnResizeKey) {
return null;
}
try {
if (!window.localStorage) {
return null;
}
const stored = JSON.parse(window.localStorage.getItem(key));
if (stored?.version !== STORAGE_VERSION || !Array.isArray(stored.widths) || stored.widths.length !== count) {
return null;
}
if (stored.widths.some((width) => !Number.isFinite(width) || width < minWidth)) {
return null;
}
return stored.widths;
} catch (error) {
return null;
}
};
const writeStoredWidths = (table, widths) => {
if (!table.dataset.columnResizeKey) {
return;
}
try {
if (!window.localStorage) {
return;
}
window.localStorage.setItem(getStorageKey(table), JSON.stringify({
version: STORAGE_VERSION,
widths: widths.map((width) => Math.round(width)),
}));
} catch (error) {
// Storage can be unavailable in privacy-restricted browser contexts.
}
};
const updateHandleState = (table, widths, minWidth) => {
const handles = table.querySelectorAll('[data-large-table-resize-handle]');
handles.forEach((handle) => {
const index = Number.parseInt(handle.dataset.largeTableResizeHandle, 10);
const current = widths[index];
const maximum = Math.max(minWidth, widths[index] + widths[index + 1] - minWidth);
if (table.id) {
handle.setAttribute('aria-controls', table.id);
}
handle.setAttribute('aria-valuemin', String(Math.round(minWidth)));
handle.setAttribute('aria-valuemax', String(Math.round(maximum)));
handle.setAttribute('aria-valuenow', String(Math.round(current)));
handle.setAttribute('aria-valuetext', `${Math.round(current)} Pixel`);
});
};
const applyWidths = (table, widths, minWidth) => {
const safeWidths = widths.map((width) => Math.max(minWidth, width));
const total = safeWidths.reduce((sum, width) => sum + width, 0);
table.style.setProperty('--large-table-grid-template-columns', safeWidths.map((width) => `${width}px`).join(' '));
table.style.setProperty('--large-table-grid-min-width', `${Math.ceil(total)}px`);
table._largeTableColumnWidths = safeWidths;
updateHandleState(table, safeWidths, minWidth);
};
const resetWidths = (table) => {
const headers = getColumnHeaders(table);
const minWidth = getMinWidth(table);
const widths = getDefaultWidths(table, headers, minWidth);
try {
window.localStorage.removeItem(getStorageKey(table));
} catch (error) {
// Storage can be unavailable in privacy-restricted browser contexts.
}
applyWidths(table, widths, minWidth);
};
const resizeColumn = (table, index, delta, persist) => {
const widths = [...table._largeTableColumnWidths];
const minWidth = getMinWidth(table);
const nextLeft = Math.max(minWidth, Math.min(widths[index] + widths[index + 1] - minWidth, widths[index] + delta));
const appliedDelta = nextLeft - widths[index];
widths[index] = nextLeft;
widths[index + 1] -= appliedDelta;
applyWidths(table, widths, minWidth);
if (persist) {
writeStoredWidths(table, widths);
}
};
const initTable = (table) => {
const headers = getColumnHeaders(table);
if (headers.length < 2) {
return;
}
const minWidth = getMinWidth(table);
const stored = readStoredWidths(table, headers.length, minWidth);
applyWidths(table, stored || getDefaultWidths(table, headers, minWidth), minWidth);
if (table.dataset.largeTableResizeInitialized === 'true') {
return;
}
table.dataset.largeTableResizeInitialized = 'true';
table.addEventListener('pointerdown', (event) => {
const handle = event.target.closest('[data-large-table-resize-handle]');
if (!handle || !table.contains(handle)) {
return;
}
event.preventDefault();
activeResize = {
table,
handle,
index: Number.parseInt(handle.dataset.largeTableResizeHandle, 10),
startX: event.clientX,
startWidths: [...table._largeTableColumnWidths],
};
handle.dataset.largeTableResizeActive = 'true';
table.dataset.columnResizeState = 'resizing';
handle.setPointerCapture?.(event.pointerId);
});
table.addEventListener('pointermove', (event) => {
if (!activeResize || activeResize.table !== table) {
return;
}
applyWidths(table, activeResize.startWidths, getMinWidth(table));
resizeColumn(table, activeResize.index, event.clientX - activeResize.startX, false);
});
table.addEventListener('pointerup', () => {
if (!activeResize || activeResize.table !== table) {
return;
}
writeStoredWidths(table, table._largeTableColumnWidths);
activeResize.handle.dataset.largeTableResizeActive = 'false';
table.dataset.columnResizeState = 'idle';
activeResize = null;
});
table.addEventListener('keydown', (event) => {
const handle = event.target.closest('[data-large-table-resize-handle]');
if (!handle || !table.contains(handle)) {
return;
}
const index = Number.parseInt(handle.dataset.largeTableResizeHandle, 10);
const step = event.shiftKey ? KEYBOARD_LARGE_STEP : KEYBOARD_STEP;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
resizeColumn(table, index, event.key === 'ArrowRight' ? step : -step, true);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
const minWidth = getMinWidth(table);
const current = table._largeTableColumnWidths[index];
const target = event.key === 'Home' ? minWidth : current + table._largeTableColumnWidths[index + 1] - minWidth;
resizeColumn(table, index, target - current, true);
}
});
table.addEventListener('click', (event) => {
const reset = event.target.closest('[data-large-table-reset-widths]');
if (reset && table.contains(reset)) {
resetWidths(table);
}
});
};
window.sgInitLargeTableColumnResize = (options = {}) => {
const root = options.root || document;
getTables(root).forEach(initTable);
};
document.addEventListener('DOMContentLoaded', () => window.sgInitLargeTableColumnResize(), { once: true });
})();
-365
View File
@@ -1,365 +0,0 @@
(function initTooltipOverlayModule() {
let tooltipId = 0;
const states = new WeakMap();
const tooltipStates = new Set();
const getViewport = () => ({
width: window.visualViewport?.width || window.innerWidth,
height: window.visualViewport?.height || window.innerHeight,
});
const getPixelValue = (propertyName) => {
const rootStyles = getComputedStyle(document.documentElement);
const rawValue = rootStyles.getPropertyValue(propertyName).trim();
const rootFontSize = parseFloat(rootStyles.fontSize) || 16;
const value = parseFloat(rawValue);
if (Number.isNaN(value)) {
return 0;
}
if (rawValue.endsWith('rem')) {
return value * rootFontSize;
}
return value;
};
const getElements = (wrap) => ({
trigger: wrap.querySelector('[data-component-part="tooltip-trigger"]'),
panel: wrap.querySelector('[data-component-part="tooltip-panel"]'),
});
const ensureAccessibility = (state) => {
const { trigger, panel } = state;
panel.setAttribute('role', 'tooltip');
if (!panel.id) {
tooltipId += 1;
panel.id = `sg-tooltip-panel-${tooltipId}`;
}
const describedBy = (trigger.getAttribute('aria-describedby') || '')
.split(/\s+/)
.filter(Boolean);
if (!describedBy.includes(panel.id)) {
trigger.setAttribute('aria-describedby', [...describedBy, panel.id].join(' '));
}
const hasAccessibleName = Boolean(
trigger.getAttribute('aria-label')
|| trigger.getAttribute('aria-labelledby')
|| trigger.getAttribute('title')
|| trigger.textContent.trim(),
);
if (!hasAccessibleName && panel.textContent.trim()) {
trigger.setAttribute('aria-label', panel.textContent.trim());
}
if (!trigger.matches('a,button,input,select,textarea,[tabindex]')) {
trigger.setAttribute('tabindex', '0');
}
};
const setOpenState = (state, isOpen) => {
const { wrap, panel, trigger } = state;
wrap.dataset.tooltipOpen = String(isOpen);
panel.hidden = !isOpen;
if (trigger.matches('button,[role="button"],[aria-expanded]')) {
trigger.setAttribute('aria-expanded', String(isOpen));
}
};
const restorePanel = (state) => {
const { panel, placeholder } = state;
panel.hidden = true;
panel.removeAttribute('data-tooltip-portal');
panel.removeAttribute('data-tooltip-placement');
panel.style.removeProperty('left');
panel.style.removeProperty('top');
panel.style.removeProperty('visibility');
panel.style.removeProperty('--tooltip-arrow-offset');
if (placeholder.parentNode) {
placeholder.parentNode.insertBefore(panel, placeholder.nextSibling);
}
};
const movePanelToPortal = (state) => {
const { panel } = state;
if (panel.parentNode !== document.body) {
document.body.appendChild(panel);
}
panel.setAttribute('data-tooltip-portal', 'true');
panel.hidden = false;
panel.style.visibility = 'hidden';
};
const setArrowOffset = (state, placement, panelLeft, panelTop, panelWidth, panelHeight) => {
const { trigger, panel } = state;
const triggerRect = trigger.getBoundingClientRect();
const offset = placement === 'left' || placement === 'right'
? triggerRect.top + (triggerRect.height / 2) - panelTop
: triggerRect.left + (triggerRect.width / 2) - panelLeft;
const maxOffset = placement === 'left' || placement === 'right' ? panelHeight : panelWidth;
panel.style.setProperty('--tooltip-arrow-offset', `${Math.max(0, Math.min(offset, maxOffset))}px`);
};
const positionTooltip = (state) => {
const { trigger, panel } = state;
if (panel.hidden || panel.parentNode !== document.body) {
return;
}
const viewport = getViewport();
const safeInset = getPixelValue('--spacing-large');
const gap = getPixelValue('--spacing-small');
const triggerRect = trigger.getBoundingClientRect();
const panelWidth = panel.offsetWidth;
const panelHeight = panel.offsetHeight;
const centeredLeft = triggerRect.left + (triggerRect.width / 2) - (panelWidth / 2);
const centeredRight = centeredLeft + panelWidth;
const hasBottomSpace = triggerRect.bottom + gap + panelHeight <= viewport.height - safeInset;
const hasTopSpace = triggerRect.top - gap - panelHeight >= safeInset;
const hasLeftSpace = triggerRect.left - gap - panelWidth >= safeInset;
const hasRightSpace = triggerRect.right + gap + panelWidth <= viewport.width - safeInset;
let placement = 'bottom';
if (centeredRight > viewport.width - safeInset && hasLeftSpace) {
placement = 'left';
} else if (centeredLeft < safeInset && hasRightSpace) {
placement = 'right';
} else if (!hasBottomSpace && hasTopSpace) {
placement = 'top';
}
let panelLeft = centeredLeft;
let panelTop = triggerRect.bottom + gap;
if (placement === 'top') {
panelTop = triggerRect.top - gap - panelHeight;
} else if (placement === 'left') {
panelLeft = triggerRect.left - gap - panelWidth;
panelTop = triggerRect.top + (triggerRect.height / 2) - (panelHeight / 2);
} else if (placement === 'right') {
panelLeft = triggerRect.right + gap;
panelTop = triggerRect.top + (triggerRect.height / 2) - (panelHeight / 2);
}
panelLeft = Math.max(safeInset, Math.min(panelLeft, viewport.width - safeInset - panelWidth));
panelTop = Math.max(safeInset, Math.min(panelTop, viewport.height - safeInset - panelHeight));
panel.dataset.tooltipPlacement = placement;
state.wrap.dataset.tooltipPlacement = placement;
panel.style.left = `${Math.round(panelLeft)}px`;
panel.style.top = `${Math.round(panelTop)}px`;
setArrowOffset(state, placement, panelLeft, panelTop, panelWidth, panelHeight);
panel.style.visibility = 'visible';
};
const openTooltip = (state) => {
tooltipStates.forEach((otherState) => {
if (otherState !== state) {
closeTooltip(otherState);
}
});
movePanelToPortal(state);
setOpenState(state, true);
positionTooltip(state);
};
const closeTooltip = (state) => {
state.pinned = false;
state.hoverTrigger = false;
state.hoverPanel = false;
state.focused = false;
state.dismissed = false;
setOpenState(state, false);
restorePanel(state);
};
const closeIfInactive = (state) => {
if (!state.pinned && !state.hoverTrigger && !state.hoverPanel && !state.focused) {
setOpenState(state, false);
restorePanel(state);
}
};
const togglePinned = (state) => {
if (state.pinned) {
state.pinned = false;
state.dismissed = true;
setOpenState(state, false);
restorePanel(state);
return;
}
state.pinned = true;
state.dismissed = false;
openTooltip(state);
};
const initTooltip = (wrap) => {
if (states.has(wrap)) {
return;
}
const elements = getElements(wrap);
if (!elements.trigger || !elements.panel || !elements.panel.parentNode) {
return;
}
const placeholder = document.createComment('sg-tooltip-panel-placeholder');
elements.panel.parentNode.insertBefore(placeholder, elements.panel);
const state = {
wrap,
trigger: elements.trigger,
panel: elements.panel,
placeholder,
pinned: false,
hoverTrigger: false,
hoverPanel: false,
focused: false,
dismissed: false,
};
wrap.dataset.tooltipInit = 'true';
wrap.dataset.tooltipPlacement = 'bottom';
states.set(wrap, state);
tooltipStates.add(state);
ensureAccessibility(state);
setOpenState(state, false);
wrap.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
if (state.dismissed) {
return;
}
state.hoverTrigger = true;
openTooltip(state);
});
wrap.addEventListener('pointerleave', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
state.hoverTrigger = false;
state.dismissed = false;
if (!state.panel.contains(event.relatedTarget)) {
closeIfInactive(state);
}
});
state.panel.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
if (!state.dismissed) {
state.hoverPanel = true;
openTooltip(state);
}
});
state.panel.addEventListener('pointerleave', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
state.hoverPanel = false;
state.dismissed = false;
closeIfInactive(state);
});
wrap.addEventListener('focusin', () => {
if (!state.dismissed) {
state.focused = true;
openTooltip(state);
}
});
wrap.addEventListener('focusout', (event) => {
if (wrap.contains(event.relatedTarget) || state.panel.contains(event.relatedTarget)) {
return;
}
state.focused = false;
state.dismissed = false;
closeIfInactive(state);
});
state.trigger.addEventListener('click', (event) => {
togglePinned(state);
});
state.trigger.addEventListener('keydown', (event) => {
const isNativeInteractive = state.trigger.matches('a,button,input,select,textarea');
if (!isNativeInteractive && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
togglePinned(state);
}
});
};
const initializeWithin = (root) => {
if (root.nodeType === Node.DOCUMENT_NODE) {
root.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip);
return;
}
if (root.nodeType !== Node.ELEMENT_NODE) {
return;
}
const owningWrap = root.closest('.sg-tooltip-wrap');
if (owningWrap) {
initTooltip(owningWrap);
}
if (root.matches('.sg-tooltip-wrap')) {
initTooltip(root);
}
root.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip);
};
const bootstrap = () => {
window.sgInitTooltips = (options = {}) => {
const root = options.root || document.body;
initializeWithin(root);
};
window.sgInitTooltips();
const observer = new MutationObserver((records) => {
records.forEach((record) => {
record.addedNodes.forEach(initializeWithin);
});
});
observer.observe(document.body, { childList: true, subtree: true });
const closeFromOutside = (event) => {
tooltipStates.forEach((state) => {
if (!state.wrap.contains(event.target) && !state.panel.contains(event.target)) {
closeTooltip(state);
}
});
};
document.addEventListener('pointerdown', closeFromOutside, true);
document.addEventListener('touchstart', closeFromOutside, true);
document.addEventListener('click', closeFromOutside, true);
const repositionOpenTooltips = () => {
document.querySelectorAll('.sg-tooltip-wrap[data-tooltip-open="true"]').forEach((wrap) => {
const state = states.get(wrap);
if (state) {
positionTooltip(state);
}
});
};
window.addEventListener('resize', repositionOpenTooltips);
window.addEventListener('scroll', repositionOpenTooltips, true);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
} else {
bootstrap();
}
})();
@@ -199,6 +199,7 @@
<tr><td>layout-pulldown-padding-inline</td><td>compact-interaction-padding-horizontal</td><td>Innenabstand links für Pulldown-Trigger und Select-Felder.</td></tr>
<tr><td>layout-pulldown-chevron-offset</td><td>compact-interaction-padding-horizontal</td><td>Rechter Innenabstand des Chevron-Icons im Pulldown.</td></tr>
<tr><td>layout-pulldown-chevron-reserved-space</td><td>spacing-large</td><td>Reservierte Breite zwischen Pulldown-Text und rechtem Chevron-Bereich.</td></tr>
<tr><td>layout-pulldown-panel-form-mobile-width</td><td>90%</td><td>Zielbreite für Pulldown-Panels mit Formularinhalt auf Mobile; wird durch die Viewport-Kappe begrenzt.</td></tr>
<tr><td>layer-pulldown-panel</td><td>dimension-layer-pulldown-panel</td><td>Layer-Stufe geöffneter Pulldown-Ausklappfelder über übrigen Seiteninhalten.</td></tr>
<tr><td>layout-multiselect-pulldown-panel-desktop-width</td><td>dimension-multiselect-pulldown-panel-desktop-width</td><td>Semantische Obergrenze für die Panelbreite geöffneter Pulldowns; das Panel darf über Triggerbreite wachsen, bleibt aber durch diese Breite und die Viewport-Kappe begrenzt.</td></tr>
<tr><td>surface-menu-panel-portal</td><td>color-light-grey</td><td>Fläche des Sandwich-Menü-Panels für große und kleine Variante.</td></tr>
@@ -223,10 +224,6 @@
<tr><td>text-help-icon</td><td>color-font-dark</td><td>Textfarbe im Help-Icon.</td></tr>
<tr><td>text-help-panel</td><td>color-font-dark</td><td>Textfarbe im Help-Panel.</td></tr>
<tr><td>layout-help-panel-width</td><td>calc(interaction-height * 8)</td><td>Standardbreite des Help-Panels.</td></tr>
<tr><td>surface-tooltip</td><td>color-white</td><td>Fläche der generischen Tooltip-Sprechblase.</td></tr>
<tr><td>text-tooltip</td><td>color-font-dark</td><td>Textfarbe der generischen Tooltip-Sprechblase.</td></tr>
<tr><td>layout-tooltip-width</td><td>calc(interaction-height * 8)</td><td>Standardbreite der generischen Tooltip-Sprechblase.</td></tr>
<tr><td>radius-tooltip</td><td>radius-interaction</td><td>Radius der generischen Tooltip-Sprechblase.</td></tr>
<tr><td>icon-sandwich-line-portal</td><td>color-font-dark</td><td>Linienfarbe des Sandwich-Icons für große und kleine Variante.</td></tr>
</tbody></table>
+1 -18
View File
@@ -129,16 +129,7 @@
<h3 class="sg-sub-heading sg-section-h3">Multiselektions-Pulldown</h3>
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
<tr><td>layout-multiselect-panel-width</td><td>layout-multiselect-pulldown-panel-desktop-width</td><td>Feste Breite des Multiselektions-Pulldown-Panels auf allen Viewports innerhalb der vorhandenen Viewport-Kappe.</td></tr>
<tr><td>layout-multiselect-panel-available-width</td><td>Viewportbreite minus 2 × spacing-large</td><td>Viewport-Kappe für das Multiselektions-Pulldown-Panel mit gleichmäßigem Außenabstand.</td></tr>
<tr><td>layout-multiselect-chapter-gap</td><td>spacing-large</td><td>Einheitlicher vertikaler Abstand zwischen allen direkten Elementen eines Multiselect-Blocks.</td></tr>
<tr><td>layout-multiselect-column-gap</td><td>spacing-large</td><td>Horizontaler Abstand zwischen Aktivierungsschalter und Label in den Pattern-Zeilen.</td></tr>
<tr><td>layout-multiselect-control-gap</td><td>spacing-small</td><td>Standardabstand innerhalb der Slider-Zeilen.</td></tr>
<tr><td>layout-multiselect-visual-control-gap</td><td>2 × spacing-small</td><td>Visueller Abstand zwischen Kopfzeile und Interaktionselement bei Radio- und Pulldown-Zeilen.</td></tr>
<tr><td>layout-multiselect-control-row-height</td><td>interaction-height</td><td>Höhe der Kopfzeile in den filterbaren Pulldown-Zeilen.</td></tr>
<tr><td>layout-multiselect-value-min-width</td><td>1.25 × interaction-height</td><td>Mindestbreite der Slider-Wertanzeige.</td></tr>
<tr><td>typography-multiselect-label-line-height</td><td>line-height-base</td><td>Zeilenhöhe der Labels in den filterbaren Pulldown-Zeilen.</td></tr>
<tr><td>opacity-multiselect-disabled</td><td>disabled-opacity</td><td>Deckkraft deaktivierter Multiselect-Inhalte.</td></tr>
<tr><td>layout-multiselect-pulldown-label-column-width-fallback</td><td>max-content</td><td>Fallback-Breite der gemeinsamen Labelspalte im Panel, bis die längste Labelbreite per Pattern-Logik ermittelt wurde.</td></tr>
</tbody></table>
<h3 class="sg-sub-heading sg-section-h3">Overlay Card</h3>
@@ -157,14 +148,6 @@
<tr><td>layout-delete-confirmation-target-dim-opacity</td><td>opacity-delete-confirmation-target-dim</td><td>Deckkraft der grauen Überlagerung auf dem zu löschenden Objekt.</td></tr>
<tr><td>layer-delete-confirmation-overlay</td><td>dimension-layer-delete-confirmation-overlay</td><td>Z-Index-Layer des schwebenden Bestätigungsfensters.</td></tr>
</tbody></table>
<h3 class="sg-sub-heading sg-section-h3">Datei-Upload</h3>
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
<tr><td>surface-file-upload-dropzone</td><td>surface-form-preview</td><td>Hintergrundfläche des auswählbaren Upload-Bereichs.</td></tr>
<tr><td>surface-file-upload-file-item</td><td>surface-control-default</td><td>Hintergrundfläche eines Eintrags in der Dateiliste.</td></tr>
<tr><td>text-file-upload-meta</td><td>text-control-disabled</td><td>Textfarbe für Dateigröße und ergänzende Dateimetadaten.</td></tr>
<tr><td>text-file-upload-error</td><td>color-signal-red</td><td>Textfarbe für Validierungs- und Upload-Fehlermeldungen.</td></tr>
</tbody></table>
</section>
</body>
-2
View File
@@ -2,8 +2,6 @@
@import "./styles/02-base.css";
@import "./styles/03-typography-helpers.css";
@import "./styles/10-components-interactive-elements.css";
@import "./styles/47-patterns-file-upload.css";
@import "./styles/48-patterns-history-graph.css";
@import "./styles/20-patterns-portal-header.css";
@import "./styles/21-patterns-options-row.css";
@import "./styles/22-patterns-object-card.css";
+4 -6
View File
@@ -16,7 +16,6 @@
--color-process-inactive: #FFAE79;
--color-signal-green: #009101;
--color-signal-green-light: #65A665;
--color-signal-green-red: #C0624D;
--color-signal-yellow: #9C7A00;
--color-ampel-hellgelb: #D3B962;
--color-signal-red: #9B3B2F;
@@ -38,7 +37,6 @@
--surface-card-header-muted: var(--color-darkbrown);
--divider-card-segment: var(--color-white);
--text-card-header: var(--color-font-light);
--text-card-signal: var(--color-font-light);
--text-card-body: var(--color-font-dark);
--text-card-transparent: var(--color-font-light);
--layout-card-body-content-justify: flex-start;
@@ -64,7 +62,6 @@
--surface-menu-panel-portal: var(--color-light-grey);
--surface-help-icon: var(--color-medium-grey);
--surface-help-panel: var(--color-light-grey);
--surface-tooltip: var(--color-white);
--surface-pulldown-panel: var(--color-light-grey);
--surface-activatable-remove: var(--surface-control-default);
--surface-slider-track: var(--color-medium-grey);
@@ -79,8 +76,10 @@
--layout-pulldown-chevron-offset: var(--compact-interaction-padding-horizontal);
--layout-tab-navigation-large-padding-inline: var(--interaction-padding-horizontal);
--layout-pulldown-chevron-reserved-space: var(--spacing-large);
--layout-pulldown-panel-form-mobile-width: 90%;
--layer-pulldown-panel: var(--dimension-layer-pulldown-panel);
--layout-multiselect-pulldown-panel-desktop-width: var(--dimension-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-pulldown-label-column-width-fallback: max-content;
--layout-input-label-width: var(--dimension-input-label-width);
--layout-input-field-desktop-width: var(--dimension-input-field-desktop-width);
--layout-input-field-max-width: var(--dimension-input-field-max-width);
@@ -90,8 +89,6 @@
--layout-mode-toggle-local-width-factor: 3;
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
--layout-help-panel-width: calc(var(--interaction-height) * 8);
--layout-tooltip-width: calc(var(--interaction-height) * 8);
--radius-tooltip: var(--radius-interaction);
--text-control-default: var(--color-font-dark);
--text-control-disabled: var(--color-dark-grey);
--text-button-process: var(--color-font-light);
@@ -101,7 +98,6 @@
--text-menu-link-portal: var(--color-font-dark);
--text-help-icon: var(--color-font-dark);
--text-help-panel: var(--color-font-dark);
--text-tooltip: var(--color-font-dark);
--text-activatable-remove: var(--text-control-default);
--text-hyperlink: var(--color-font-hyperlink);
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
@@ -144,6 +140,7 @@
--layout-preview-align-items: flex-start;
/* Semantic pattern tokens: Portal header */
--surface-portal-background: var(--color-background-purple);
--surface-portal-header: var(--color-darkblue);
--surface-portal-header-tab: var(--color-midblue);
--surface-portal-header-tab-active: var(--color-light-grey);
@@ -309,6 +306,7 @@
--dimension-input-label-width: 9rem;
--dimension-input-field-desktop-width: 400px;
--dimension-input-field-max-width: 600px;
--dimension-email-body-max-width: 600px;
--dimension-search-field-width: 15.3rem;
--dimension-public-content-width: 100%;
--dimension-layer-pulldown-panel: 40;
+1 -1
View File
@@ -8,7 +8,7 @@ body {
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
color: var(--color-font-dark);
background: var(--color-background-purple);
background: var(--surface-portal-background);
margin: 0;
padding: var(--spacing-small);
}
@@ -139,13 +139,6 @@
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -1239,134 +1232,6 @@
right: 0;
}
.sg-tooltip-wrap {
display: contents;
}
.sg-tooltip-panel {
position: absolute;
z-index: var(--layer-pulldown-panel);
display: block;
width: var(--layout-tooltip-width);
max-width: calc(100vw - (2 * var(--spacing-large)));
box-sizing: border-box;
overflow-wrap: anywhere;
padding: var(--spacing-large);
border-radius: var(--radius-tooltip);
color: var(--text-tooltip);
background: var(--surface-tooltip);
box-shadow: var(--shadow-overlay);
}
.sg-tooltip-panel[hidden] {
display: none;
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
top: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
bottom: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
top: 50%;
right: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
top: 50%;
left: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-panel::before {
position: absolute;
width: 0;
height: 0;
border-style: solid;
content: "";
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
top: calc(var(--spacing-small) * -2);
left: 50%;
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
bottom: calc(var(--spacing-small) * -2);
left: 50%;
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
top: 50%;
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
top: 50%;
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"] {
position: fixed;
top: 0;
left: 0;
z-index: var(--layer-pulldown-panel);
max-width: calc(100vw - (2 * var(--spacing-large)));
transform: none;
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before {
top: calc(var(--spacing-small) * -2);
left: var(--tooltip-arrow-offset);
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before {
bottom: calc(var(--spacing-small) * -2);
left: var(--tooltip-arrow-offset);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before {
top: var(--tooltip-arrow-offset);
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before {
top: var(--tooltip-arrow-offset);
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-pulldown-panel {
position: absolute;
top: calc(100% + var(--spacing-small));
@@ -1713,52 +1578,3 @@
color: var(--text-hyperlink);
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
@@ -91,44 +91,19 @@
.sg-options-row--left-only .sg-options-row__left {
order: 0;
width: 100%;
padding: 0;
box-shadow: none;
}
.sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
.sg-options-row__left > .sg-pulldown-demo,
.sg-options-row__left > .sg-input-single-line-wrap,
.sg-options-row__left > .sg-search-field-row {
flex: 1 1 calc(50% - var(--layout-options-row-group-gap));
min-width: 0;
}
.sg-options-row__left:has(> .sg-pulldown-demo ~ .sg-pulldown-demo) > .sg-pulldown-demo {
flex-basis: calc(50% - (var(--layout-options-row-group-gap) / 2));
}
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:nth-last-child(2):nth-child(odd)) > .sg-pulldown-demo:nth-last-child(2),
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:last-child:nth-child(odd)) > .sg-pulldown-demo:last-child {
flex-basis: 100%;
}
.sg-options-row--left-only .sg-options-row__left > .sg-pulldown-demo {
flex-basis: 100%;
}
.sg-options-row__left > .sg-search-field-row {
flex: 0 0 100%;
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row > .sg-search-field-input {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row .sg-input-single-line {
width: 100%;
min-width: 0;
flex: 0 0 auto;
}
.sg-options-row__left > .sg-input-single-line-wrap .sg-input-single-line,
@@ -136,6 +111,15 @@
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-pulldown-panel-form-mobile-width),
calc(100vw - (2 * var(--spacing-large)))
);
min-width: 0;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
@@ -75,63 +75,39 @@
padding-left: var(--layout-form-sections-interaction-indent);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] {
--layout-multiselect-panel-width: var(--layout-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-panel-available-width: calc(100vw - (2 * var(--spacing-large)));
--layout-multiselect-chapter-gap: var(--spacing-large);
--layout-multiselect-column-gap: var(--spacing-large);
--layout-multiselect-control-gap: var(--spacing-small);
--layout-multiselect-visual-control-gap: calc(var(--spacing-small) * 2);
--layout-multiselect-control-row-height: var(--interaction-height);
--layout-multiselect-value-min-width: calc(var(--interaction-height) * 1.25);
--typography-multiselect-label-line-height: var(--line-height-base);
--opacity-multiselect-disabled: var(--disabled-opacity);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > :not(.sg-form-sections-card__chapter-title) + :not(.sg-form-sections-card__chapter-title) {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > * + * {
margin-top: var(--layout-multiselect-chapter-gap);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-form-sections-card__chapter-title + * {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-slider-row + .sg-slider-row,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-checkbox-field-option + .sg-checkbox-field-option,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-radio-activatable-group + .sg-radio-activatable-group {
margin-top: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo .sg-pulldown {
width: 100%;
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-pulldown-panel__row + .sg-pulldown-panel__row {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
min-width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
max-width: var(--layout-multiselect-panel-available-width);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
width: 100%;
max-width: 100%;
--sg-multiselect-label-column-width: var(--layout-multiselect-pulldown-label-column-width-fallback);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] .sg-pulldown-panel__label {
white-space: normal;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) auto;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr) auto;
align-items: center;
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-control-gap);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable {
@@ -139,43 +115,37 @@
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider-value,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
grid-column: 3;
min-width: 0;
max-width: 100%;
width: 100%;
max-width: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
grid-column: 4;
margin: 0;
min-width: var(--layout-multiselect-value-min-width);
min-width: 0;
line-height: 1;
align-self: center;
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
@@ -184,26 +154,22 @@
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
align-items: center;
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-visual-control-gap);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
margin: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 3;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
@@ -212,45 +178,119 @@
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > :not(.sg-mode-toggle) {
pointer-events: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--layout-multiselect-control-row-height) auto;
row-gap: var(--layout-multiselect-visual-control-gap);
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header {
display: flex;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
align-items: center;
gap: var(--layout-multiselect-column-gap);
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label {
margin: 0;
min-width: 0;
line-height: var(--typography-multiselect-label-line-height);
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1;
grid-row: 2;
grid-column: 3;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled {
opacity: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-demo .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
@media (max-width: 48rem) {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
grid-template-columns: max-content minmax(0, 1fr) auto;
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1 / -1;
grid-row: 2;
}
}
.sg-form-sections-card__actions {
+4 -14
View File
@@ -136,27 +136,22 @@
.sg-card-segment--signal-red {
background: var(--color-signal-red);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-green {
background: var(--color-signal-green);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-green-light {
background: var(--color-signal-green-light);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-red {
background: var(--color-signal-green-red);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-yellow {
background: var(--color-signal-yellow);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--gray {
@@ -176,11 +171,6 @@
--text-card-body: var(--color-font-light);
}
.sg-card.sg-card--notification-signal-green-red {
--text-card-header: var(--color-font-light);
--text-card-body: var(--color-font-light);
}
.sg-card--content-card {
--surface-card-header-primary: var(--surface-content-block-card-title);
--surface-card-body: var(--surface-content-block-card-content);
@@ -71,32 +71,16 @@
width: 100%;
}
.sg-large-table[data-column-resize="true"] {
overflow: hidden;
}
.sg-large-table__viewport {
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
width: auto;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
}
.sg-large-table__title-segment {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
box-sizing: border-box;
}
.sg-large-table__row {
display: grid;
grid-template-columns: var(--large-table-grid-template-columns, repeat(5, minmax(0, 1fr)));
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: stretch;
gap: 0;
width: 100%;
@@ -104,11 +88,6 @@
background: var(--color-white);
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row,
.sg-large-table[data-column-resize="true"] .sg-large-table__title-segment {
width: max(100%, var(--large-table-grid-min-width, 0px));
}
.sg-large-table__row--hidden {
display: none;
}
@@ -140,59 +119,16 @@
white-space: normal;
}
.sg-large-table__row--load-more,
.sg-large-table__cell--load-more,
.sg-large-table__load-more-card {
min-width: 0;
.sg-large-table__load-more-layout {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row--load-more {
grid-template-columns: minmax(0, 1fr);
}
.sg-large-table__cell--header {
position: relative;
padding: 0;
}
.sg-large-table__resize-handle {
position: absolute;
z-index: 1;
top: 0;
right: -0.25rem;
bottom: 0;
width: 0.5rem;
cursor: col-resize;
touch-action: none;
}
.sg-large-table__resize-handle::after {
content: "";
position: absolute;
top: 25%;
bottom: 25%;
left: 50%;
width: 0.1rem;
transform: translateX(-50%);
background: var(--color-darkblue);
box-shadow: -0.15rem 0 0 var(--color-darkblue), 0.15rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:hover::after,
.sg-large-table__resize-handle:focus-visible::after,
.sg-large-table[data-column-resize-state="resizing"] .sg-large-table__resize-handle[data-large-table-resize-active="true"]::after {
background: var(--color-darkblue);
box-shadow: -0.2rem 0 0 var(--color-darkblue), 0.2rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:focus-visible {
outline: 2px solid var(--color-darkblue);
outline-offset: -1px;
}
.sg-large-table__sort-button {
display: flex;
align-items: center;
@@ -1,91 +0,0 @@
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
@@ -1,45 +0,0 @@
/* ========================================================= */
/* Pattern: History Graph */
/* ========================================================= */
.sg-history-graph {
--layout-history-graph-connector-width: var(--score-marker-width);
--surface-history-graph-connector: var(--chart-marker-line);
width: 100%;
}
.sg-history-graph__events {
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.sg-history-graph__event {
position: relative;
width: 100%;
}
.sg-history-graph__event:not(:last-child)::after {
position: absolute;
top: 100%;
left: 50%;
width: var(--layout-history-graph-connector-width);
height: var(--spacing-large);
background: var(--surface-history-graph-connector);
content: "";
transform: translateX(-50%);
}
.sg-history-graph__card {
width: 100%;
max-width: var(--layout-object-card-min-width);
margin-inline: auto;
}
.sg-history-graph__navigation {
width: 100%;
}
-322
View File
@@ -1,322 +0,0 @@
# Technische Architektur
Stand: 2026-04-08
Status: Verbindlicher Architektur-Master
Dokumentklasse: normative
## 1. Zweck und Geltung
Dieses Dokument ist die verbindliche Architekturvorgabe fuer die Entwicklung hochmodularer Webportale in diesem Repository. Es steuert Analyse, Implementierung, Refactoring und Review durch Mensch und LLM.
Verbindliche Betriebsregel:
- Entwicklung und erste Integration erfolgen lokal auf `feature/<thema>` im lokalen Docker-Stack.
- `main` ist der gemeinsame, auf DEV betriebene Integrationsstand. Ein Feature wird erst nach lokaler Verifikation und expliziter Freigabe nach `main` gemergt.
- PROD wird ausschliesslich durch das bestehende Release-Skript mit einem expliziten Release-Tag eines in DEV getesteten `main`-Commits ausgerollt und nur nach expliziter User-Freigabe angefasst.
Es definiert verbindlich:
- Modul-, Prozess-, Frontend-, API- und Datenbankgrenzen
- Ownership und oeffentliche Schnittstellen
- Standard-Scope fuer Analyse und Implementierung
- Wiederverwendungs- und Redundanzregeln
- Regeln fuer neue Module, Submodule und zentrale Bausteine
Dieses Dokument ist kein Inventar konkreter Tabellen, Prozesse, Dateien oder Runtime-Artefakte. Solche Details liegen in den zustaendigen Modul-, Prozess- oder DB-Vertraegen.
## 2. Architekturprinzipien
### 2.1 Module
Ein Modul ist ein fachlich oder technisch gekapselter Contract, nicht nur eine Verzeichnisstruktur.
Verbindliche Regel:
- jedes Modul besitzt genau eine klar abgegrenzte Primaerverantwortung
- jedes Modul kapselt seine Domain-Logik, Datenzugriffe, externen Schnittstellen, internen Datenstrukturen und Speicherdetails
- jedes Modul definiert eine explizite oeffentliche Schnittstelle fuer andere Module
- jedes Modul besitzt definierte Owned Artefakte und Schreibrechte
- interne Implementierungen anderer Module duerfen nicht direkt genutzt werden
- jede Modulgrenze muss als potenzielle spaetere Service-Grenze behandelbar bleiben
### 2.2 Prozesse
Ein Prozess ist ein sequenzieller Hauptablauf innerhalb genau eines owning Hauptmoduls.
Verbindliche Regel:
- ein Hauptmodul besitzt beliebig viele Hauptprozesse; jeder Hauptprozess gehoert genau einem Hauptmodul
- ein Hauptprozess darf Submodule desselben Hauptmoduls nutzen
- fachliche Hauptprozesse ueber Hauptmodulgrenzen hinweg sind nicht zulaessig
- Batching, Parallelisierung, Retry, Resume, Fehlerklassifikation, Statusermittlung und Ergebnisvertrag gehoeren in den owning Prozessvertrag
- zusaetzliche harte Schreib-, Feld- oder Datenbankschranken sind nur zulaessig, wenn sie explizit im Prozess- oder DB-Vertrag festgelegt sind
Verbindliche Kettenarten:
- asynchrone Prozessketten: ein Startprozess stoesst Folgeprozesse an, diese laufen als eigene Runs weiter
- synchrone Prozessketten: Subprozesse haengen direkt aneinander; jeder Schritt stoesst nur den naechsten an
- der Kettenmodus muss pro Prozessvertrag explizit festgelegt sein
- die Wahl des Kettenmodus erfolgt jeweils nach fachlichem Sinn und Prozessvertrag
Aktive Prozesswahrheit:
`docs/modules/<modul>/<submodul>/processes/`
Verbindliche Regel:
- Prozess- und Sub-Prozess-Dokumente unter diesem Pfad sind die aktive fachliche Wahrheit
- Prozessnomenklatur folgt `component.subcomponent.process_name`
- jeder Hauptprozess und jeder Sub-Prozess besitzt genau einen owning Modulpfad
- `docs/system_components/` darf fuer aktive Prozessreferenzen nicht verwendet werden
### 2.3 Implementierung
Module, Prozesse und Sub-Prozesse sind technisch so scharf wie moeglich zu trennen.
Verbindliche Regel:
- jedes Modul besitzt eine interne API oder Zugriffsschicht
- Cross-Modul-Kommunikation erfolgt nur ueber definierte Modul-Schnittstellen
- direkte Zugriffe auf interne Implementierungen oder Daten anderer Module sind verboten
- neue APIs im geschuetzten Bereich sind standardmaessig `protected`; die Abo-Pruefung greift vor jeder Datenlieferung
- Module, Prozesse und Sub-Prozesse muessen getrennt entwickelbar, testbar, ausrollbar, ueberwachbar, debugbar und optimierbar sein
- technische Kopplung zwischen Prozessen und Sub-Prozessen ist auf das notwendige Minimum zu reduzieren
- Module duerfen keine impliziten Abhaengigkeiten besitzen, die eine spaetere Extraktion verhindern
### 2.4 Frontend
Das Frontend ist bewusst minimalistisch. Ziel ist eine kleine Zahl konsistenter, wiederverwendbarer UI-Bausteine statt vieler seiten- oder feature-spezifischer Sonderkomponenten.
Verbindliche Regel:
- Seiten und Views komponieren Daten, Layouts und Komponenten, erzeugen aber keine Fachlogik
- UI-Komponenten duerfen keine fachliche Primaerlogik, fachliche Dateninterpretation oder eigenstaendige Fachwahrheit besitzen
- fachliche UI-Komponenten, fachliche Darstellungsregeln und fachlich interpretierende UI-Logik gehoeren zum owning Fachmodul
- fachlich neutrale UI-Komponenten, Layout-Komponenten, Formatierungshelfer, Interaktionsmuster und Frontend-Utilities gehoeren nach `shared`
- neue oder geaenderte Frontend-Komponenten muessen zuerst gegen bestehende Komponenten, Patterns und `shared`-Bausteine geprueft werden
- Varianten bestehender Komponenten sind ueber Props, Konfiguration oder dokumentierte Erweiterungspunkte umzusetzen, nicht durch Copy-Paste
- Sortierung, Filterung und Suche werden serverseitig im owning Modul bzw. in der API umgesetzt; das Frontend rendert nur die Ergebnisse
- Karten, Tabellen, Filter, Tabs, Buttons, Statusanzeigen, Ladezustaende, Empty States, Fehlermeldungen und Detailansichten sind als wiederverwendbare Patterns zu behandeln
- seitenlokale Sonderkomponenten sind nur zulaessig, wenn sie nachweislich nicht sinnvoll wiederverwendbar sind
- API-Responses duerfen frontendnah komponiert sein, aber keine Fachlogik aus dem owning Modul in API oder Frontend verschieben
- Seiten mit externen Redirect- oder Callback-Flows muessen Browser-History und Ruecksprungverhalten bewusst behandeln; der verbindliche Detailvertrag liegt zentral unter `docs/modules/shared/ui_foundation/`.
### 2.4.1 Mehrsprachigkeit
Mehrsprachigkeit ist ein verbindlicher Teil der Frontend-Architektur und gilt identisch fuer statische Texte, dynamisch gerenderte Texte und interaktive UI-Elemente.
Verbindliche Regel:
- jeder sichtbare GUI-String muss ueber einen i18n-Key aufgeloest werden, unabhaengig davon, ob er serverseitig, clientseitig oder aus einer API-Komposition gerendert wird
- dazu gehoeren insbesondere Navigationslabels, Tab-Titel, Buttontexte, Menuepunkte, Tooltips, Hilfetexte, Platzhalter, Empty States, Ladezustände, Fehlermeldungen, Bestätigungsdialoge, ARIA-Labels und sonstige Screenreader-Texte
- source-code-Defaults sind nur Fallback- bzw. Seed-Werte; die Anzeige-Wahrheit wird pro Locale aus dem i18n-Store bezogen
- interaktive Elemente muessen sichtbare Beschriftung und funktionalen Wert strikt trennen: der angezeigte Text ist uebersetzbar, der Wert fuer State, Transport, Routing, Filterung und Backend-Verarbeitung bleibt technisch stabil und locale-unabhaengig
- Auswahlwerte, Keys, IDs, Data-Attribute, Option-Keys, Tab-Keys und sonstige Steuerungswerte duerfen nicht aus uebersetzten Labels abgeleitet werden
- Locale-Wechsel darf ausschliesslich die Anzeigeschicht wechseln; Funktion, Auswahlzustand, Navigation, Filterlogik und Datenuebergabe muessen identisch weiter funktionieren
- wenn Text im DOM sichtbar ist, gilt er als GUI-String und unterliegt dieser Regel auch dann, wenn er clientseitig zusammengesetzt wird
- zusammengesetzte UI-Texte duerfen keine fachliche Logik enthalten; sie kombinieren nur locale-gebundene Textbausteine mit stabilen technischen Werten
## 3. Modulmodell
### 3.1 Zulaessige Modulbereiche
Fachliche Hauptmodule:
- `company`
- `ratings`
- `groups`
- `lists`
Technische Modulbereiche:
- `system`
- `shared`
Verbindliche Regel:
- fachliche Hauptmodule besitzen Business-Logik und fachliche Ownership
- `system` enthaelt nur technische Runtime-, Start-, Trigger- und Laufzeitlogik
- `shared` enthaelt nur fachlich neutrale, wiederverwendbare technische Bausteine
- konkrete Submodule, Prozesse, Artefakte und Schnittstellen werden in Modulvertraegen dokumentiert, nicht in diesem Architektur-Master
### 3.2 `shared`
`shared` ist der zentrale technische Querschnittsbereich.
Verbindliche Regel:
- `shared` darf keine Business-Logik, Fachwahrheit oder primaere fachliche Ownership besitzen
- `shared` darf Services, Komponenten, Helper, technische Schnittstellen, UI-nahe und API-nahe Bausteine bereitstellen
- ein Baustein gehoert nur nach `shared`, wenn er fachlich neutral ist und keine Verantwortung eines Fachmoduls uebernimmt
- fachliche Regeln, Dateninterpretation, Statuslogik und Entscheidungen bleiben im owning Fachmodul
- technische Querschnittslogik ohne fachlichen Inhalt muss in `shared` oder einem dort dokumentierten zentralen Baustein liegen, sobald Wiederverwendung ueber mehr als eine Stelle absehbar ist
### 3.3 `admin`
`admin` ist kein Hauptmodul und besitzt keine fachliche Ownership.
Verbindliche Regel:
- Admin-Funktionen sind nur Bedienoberflaechen auf bestehende Hauptmodule
- Admin-spezifische Fachlogik liegt im owning Hauptmodul
- ein separates fachliches Modul `admin` ist nicht zulaessig
### 3.4 `public/api/`
`public/api/` ist die HTTP-Service- und Kompositionsschicht des modularen Monolithen.
Verbindliche Regel:
- `public/api/` besitzt keine fachliche Primaerverantwortung
- `public/api/` darf Modul- oder Submodul-Schnittstellen fuer konkrete Frontend-User-Stories zusammensetzen
- `public/api/` bleibt duenn und enthaelt nur HTTP-nahe und technische Kompositionslogik
- fachliche Ownership darf nicht aus Modulen in `public/api/` wandern
## 4. Datenbankgrenze
Die Plattform nutzt aktuell genau eine gemeinsame Datenbank. Diese gemeinsame Datenbank hebt Modul- und Ownership-Grenzen nicht auf.
Verbindliche Regel:
- jede Tabelle, View, materialisierte View und DB-Funktion besitzt genau eine primaere Ownership
- primaere Ownership folgt genau einem Hauptmodul oder einem technischen Shared-Bereich
- fachliche Beziehungen sowie Ownership- und Berechtigungsschluessel verwenden stabile interne Entitaets-IDs; veraenderliche Attribute wie E-Mail, Username oder Anzeigename duerfen dafuer nicht verwendet werden
- nur das owning Modul definiert Struktur, Write-Logik, Constraints und fachliche Semantik eines DB-Artefakts
- direkte Cross-Modul-Writes in fremde Owned Tabellen sind verboten
- andere Module duerfen fremde Artefakte nur ueber definierte Schnittstellen des owning Moduls nutzen
- physische DB-Splits sind kein aktueller Standard und beduerfen einer expliziten Architekturentscheidung
Operative DB-Referenz:
`docs/database/module_db_ownership.md`
Verbindliche Regel:
- konkrete DB-Artefaktlisten gehoeren in die DB-Ownership-Dokumentation, nicht in diesen Architektur-Master
- bei Analyse, Refactoring und Implementierung wird der DB-Scope zuerst ueber Modul plus Owned DB-Artefakte bestimmt
- fremde DB-Artefakte duerfen nur bei deklarierter Leseabhaengigkeit, FK-Beziehung oder Integrationspruefung in den Scope aufgenommen werden
## 5. Dokumentation und Lesescope
Aktive Dokumentation ist komponentennah.
Verbindliche Regel:
- aktive Modul- und Submodul-Dokumentation liegt unter `docs/modules/<modul>/...`
- aktive technische Shared-Dokumentation liegt unter `docs/modules/shared/...`
- technische Runtime-Dokumentation liegt unter `docs/modules/system/...`
- fachliche Hauptprozesse liegen innerhalb des owning Modulbaums
- historische oder abgeloeste Dokumentation liegt ausserhalb des aktiven Modulbaums
- `docs/technical_architecture.md` bleibt der einzige aktive Architektur-Master
Standard-Lesescope:
- Arbeit an einem Modul: `docs/technical_architecture.md` plus `docs/modules/<modul>/...`
- Arbeit an einem Submodul: `docs/technical_architecture.md` plus `docs/modules/<modul>/<submodul>/...`
- `docs/modules/shared/...`: nur bei betroffener Shared-Schnittstelle oder moeglicher Wiederverwendung
- `docs/modules/system/...`: nur bei Runtime-, Queue-, Scheduler-, Trigger- oder Supervisor-Themen
- andere Module: nur bei explizitem Integrationsbedarf
## 6. Wiederverwendung und Redundanzverbot
Vor jeder Aenderung an App, Frontend, UI-Komponenten, UI-Patterns, API, Prozesslogik, Datenzugriff, Infrastruktur oder Dokumentation ist Wiederverwendung vor Neuerstellung zu pruefen.
Ziel ist eine Architektur ohne redundante Fachlogik, technische Inselloesungen, doppelte UI-Komponenten oder mehrfach gepflegte Varianten desselben Musters.
Verbindliche Regel:
- zuerst bestehende Muster, Komponenten, Services, Modul-Schnittstellen und `shared`-Bausteine pruefen
- vorhandene passende Muster muessen wiederverwendet, erweitert oder als zentrale Schnittstelle bereitgestellt werden
- fachliche Inhalte gehoeren immer in das owning Fachmodul und werden von dort ueber definierte Schnittstellen bereitgestellt
- fachliche Logik darf nicht in `shared`, `public/api/`, `admin`, Skripte oder fachfremde Bereiche ausgelagert oder dupliziert werden
- fachlich neutrale technische Logik gehoert nach `shared`, sofern sie wiederverwendbar ist oder Wiederverwendung absehbar ist
- Frontend-Darstellung, Interaktionslogik, Layout, Formatierung und Zustandsdarstellung sind als bestehende oder neue zentrale UI-Komponenten beziehungsweise UI-Patterns in `shared` zu pruefen
- neue `shared`-Bausteine sind nur zulaessig, wenn sie keine Fachwahrheit enthalten, keine fachliche Primaerverantwortung uebernehmen und wiederverwendbar sind
- technische Querschnittsbausteine duerfen Module entkoppeln, aber keine fachlichen Entscheidungen aus owning Modulen herausziehen
- neue Loesungen sind nur zulaessig, wenn kein passendes bestehendes Muster, keine passende Modul-Schnittstelle und kein geeigneter `shared`-Baustein existiert
- Quickfixes, Workarounds, Copy-Paste-Varianten, seitenlokale UI-Duplikate und isolierte Sonderlogik fuer Einzelstellen sind verboten
- Redundanzvermeidung muss bei Analyse, Umsetzung, Review und Refactoring aktiv nachgewiesen werden koennen
## 7. Verbotene Strukturen
Nicht zulaessig sind:
- Module als reine Ordner ohne Contract
- direkte Cross-Modul-Zugriffe auf DB-Tabellen, interne Implementierungen oder Daten anderer Module
- versteckte oder implizite Modulabhaengigkeiten
- Vermischung mehrerer Primaerverantwortungen innerhalb eines Moduls
- parallele Fachlogik ausserhalb des owning Moduls
- redundante fachliche, technische oder UI-Loesungen, obwohl ein bestehendes Modul, eine Schnittstelle, ein Pattern oder ein `shared`-Baustein erweitert werden koennte
- neue generische Frameworks oder Orchestrator-Umbauten ohne explizite Architekturentscheidung
- implizite Schutz-, Schreib- oder Datenbankschranken ohne Prozess- oder DB-Vertrag
## 8. Erweiterungsregeln
Neue Hauptmodule, Submodule oder zentrale Bausteine duerfen nur eingefuehrt werden, wenn bestehende Strukturen die Verantwortung nicht sauber tragen koennen.
Prueffragen vor jeder Erweiterung:
1. Welches bestehende Hauptmodul ist primaer betroffen?
2. Reicht eine neue Datei, ein neues Submodul oder eine neue Schnittstelle im owning Hauptmodul aus?
3. Handelt es sich um fachlich neutrale Wiederverwendung, die nach `shared` gehoert?
4. Entsteht wirklich eine neue Primaerverantwortung mit eigenen Owned Artefakten und Schreibrechten?
5. Bleibt der Handshake zum Rest des Systems auf minimale Scope-Identifier begrenzt?
6. Reduziert der neue Schnitt Kopplung und Redundanz, statt sie zu erhoehen?
Verbindliche Regel:
- neue Hauptmodule sind nur zulaessig, wenn eine neue fachliche oder technische Primaerverantwortung entsteht, die nicht sauber in `company`, `ratings`, `groups`, `lists`, `system` oder `shared` aufgeht
- neue Submodule sind nur zulaessig, wenn innerhalb eines bestehenden Hauptmoduls eine klar isolierbare Verantwortung mit eigener Schnittstelle entsteht
- neue Prozess- und Sub-Prozess-Dokumente werden im owning Modulpfad angelegt und folgen `component.subcomponent.process_name`
- `admin`, `public/api/`, `scripts/` oder `lib/` begruenden kein neues fachliches Hauptmodul
- Bequemlichkeit, Dateigroesse oder kurzfristiger Umbauaufwand rechtfertigen kein neues Hauptmodul
- vor jeder normativen Architektur- oder Modulerweiterung muss dieses Dokument aktualisiert werden
## 9. Aenderungsregel
Jede strukturelle Aenderung an Hauptmodulen, Modulgrenzen, Prozesszuordnungen, Frontend-Prinzipien, API-Grenzen, DB-Ownership oder primaeren Ownership-Prinzipien muss zuerst in diesem Dokument normativ festgelegt und danach in Detaildokumenten nachgezogen werden.
Verbindlich ist:
- `docs/technical_architecture.md` ist der einzige aktive Architektur-Master des Repositories
- andere Dokumente duerfen diese Architektur erklaeren, anwenden oder historisieren, aber keine konkurrierende Master-Struktur definieren
- konkrete Inventare wie Tabellenlisten, Submodullisten, Prozesslisten oder Runtime-Artefakte werden in Detaildokumenten gepflegt und nicht hier dupliziert
## 10. Projektspezifische Architekturvorgaben
Dieses Kapitel enthaelt Architekturentscheidungen, die fuer dieses Projekt
gelten und nicht Teil der allgemeinen, projektuebergreifend wiederverwendbaren
Modularitaet sind.
### 10.1 Fachliches Hauptmodul `market_management`
Dieses Projekt besitzt zusaetzlich das fachliche Hauptmodul
`market_management`. Seine fachliche Ownership, Datenstruktur und
Schnittstellen werden in den Modulvertraegen unter
`docs/modules/market_management/` dokumentiert.
### 10.2 GUI-/MCP-Paritaet
Die MCP-Schnittstelle ist im Grundsatz funktional paritaetisch zur GUI. Ein
MCP-Nutzer ist hinsichtlich der fachlichen Nutzung einem GUI-Nutzer
gleichzustellen.
Verbindliche Regel:
- jede fachliche Funktion, die technisch ueber das GUI-Interface verfuegbar
ist, muss auch ueber die MCP-Schnittstelle ausfuehrbar sein
- die Paritaet bezieht sich auf die technischen Moeglichkeiten des Interfaces,
nicht nur auf aktuell im GUI dargestellte Funktionen; technisch vorhandene,
im GUI nicht angezeigte Funktionen muessen trotzdem ueber MCP verfuegbar sein
- alle fachlichen Lese-, Anlege-, Aenderungs-, Archivierungs- und
Reaktivierungsmoeglichkeiten des GUI-Interfaces muessen im MCP-Interface
abgebildet sein
- GUI und MCP verwenden dieselben fachlichen Validierungen, Berechtigungen,
Statusregeln und Ownership-Grenzen; fachliche Logik darf nicht doppelt in
GUI und MCP implementiert werden
- jede Erweiterung oder Aenderung eines GUI-Anwendungsfalls erfordert zwingend
die gleichzeitige Pruefung und, falls erforderlich, Anpassung des MCP-Interfaces
- ein MCP-Nutzer darf gegenueber einem gleich berechtigten GUI-Nutzer weder
fachlich eingeschraenkt noch durch abweichende Regeln bevorzugt werden
- die Paritaet betrifft die fachliche Funktion und den Datenzugriff, nicht die
visuelle Darstellung oder den Interaktionsablauf der GUI
+2 -16
View File
@@ -146,12 +146,10 @@ function create_direct_sales_order(PDO $pdo, array $fields): array
$stmt = $pdo->prepare(
'INSERT INTO sales_order (
id, external_ref, party_id, order_source, order_date, order_status, payment_status, payment_method_id,
amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency,
market_occurrence_id, market_occurrence_day_id, imported_at, created_at, updated_at
amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, imported_at, created_at, updated_at
) VALUES (
:id, :external_ref, :party_id, \'direct\', :order_date, \'imported\', \'paid\', :payment_method_id,
:amount_net, 0, 0, 0, :total_amount, \'CHF\',
:market_occurrence_id, :market_occurrence_day_id, NOW(), NOW(), NOW()
:amount_net, 0, 0, 0, :total_amount, \'CHF\', NOW(), NOW(), NOW()
)
RETURNING id, external_ref'
);
@@ -163,8 +161,6 @@ function create_direct_sales_order(PDO $pdo, array $fields): array
':payment_method_id' => $fields[':payment_method_id'] ?? null,
':amount_net' => $fields[':amount_net'] ?? null,
':total_amount' => $fields[':total_amount'] ?? null,
':market_occurrence_id' => $fields[':market_occurrence_id'] ?? null,
':market_occurrence_day_id' => $fields[':market_occurrence_day_id'] ?? null,
]);
$row = $stmt->fetch();
@@ -394,8 +390,6 @@ function get_sales_order_detail(PDO $pdo, int $orderId): ?array
so.amount_discount,
so.total_amount,
so.currency,
md.name AS market_name,
mday.date AS market_occurrence_day_date,
COALESCE(ad.first_name, '') AS first_name,
COALESCE(ad.last_name, '') AS last_name,
COALESCE(ad.street, '') AS street,
@@ -415,10 +409,6 @@ function get_sales_order_detail(PDO $pdo, int $orderId): ?array
) ad ON TRUE
LEFT JOIN payment_method pm ON pm.id = so.payment_method_id
LEFT JOIN shipping_method sm ON sm.id = so.shipping_method_id
LEFT JOIN market_occurrence mo ON mo.id = so.market_occurrence_id
LEFT JOIN market_definition md ON md.id = mo.market_definition_id
LEFT JOIN market_occurrence_day mday
ON mday.id = so.market_occurrence_day_id AND mday.occurrence_id = mo.id
WHERE so.id = :order_id"
);
$orderStmt->execute([':order_id' => $orderId]);
@@ -505,10 +495,6 @@ function get_sales_order_detail(PDO $pdo, int $orderId): ?array
'payment_status' => (string) $order['payment_status'],
'payment_method' => (string) $order['payment_method'],
'shipping_method' => (string) $order['shipping_method'],
'market' => [
'name' => $order['market_name'] !== null ? (string) $order['market_name'] : null,
'occurrence_day_date' => $order['market_occurrence_day_date'] !== null ? (string) $order['market_occurrence_day_date'] : null,
],
'customer' => [
'first_name' => (string) $order['first_name'],
'last_name' => (string) $order['last_name'],
+1 -47
View File
@@ -41,20 +41,6 @@ $products = $data['products'];
$orderDate = trim((string) $data['orderDate']);
$payments = $data['payments'];
$billing = is_array($data['billing']) ? $data['billing'] : [];
$marketOccurrenceId = $data['marketOccurrenceId'] ?? null;
$marketOccurrenceDayId = $data['marketOccurrenceDayId'] ?? null;
if (($marketOccurrenceId === null) !== ($marketOccurrenceDayId === null)) {
json_response(422, ['ok' => false, 'error' => 'Markt und Markttag müssen gemeinsam angegeben werden.']);
}
if ($marketOccurrenceId !== null && (!ctype_digit((string) $marketOccurrenceId) || (int) $marketOccurrenceId <= 0)) {
json_response(422, ['ok' => false, 'error' => 'Ungültiger Markt.']);
}
if ($marketOccurrenceDayId !== null && (!ctype_digit((string) $marketOccurrenceDayId) || (int) $marketOccurrenceDayId <= 0)) {
json_response(422, ['ok' => false, 'error' => 'Ungültiger Markttag.']);
}
if (!is_array($products) || count($products) === 0) {
json_response(422, ['ok' => false, 'error' => 'No products specified']);
@@ -129,27 +115,6 @@ try {
$pdo = connect_database($env);
$pdo->beginTransaction();
if ($marketOccurrenceId !== null) {
$marketLinkStmt = $pdo->prepare(
'SELECT 1
FROM market_occurrence o
INNER JOIN market_definition m ON m.id = o.market_definition_id
INNER JOIN market_occurrence_day d ON d.occurrence_id = o.id
WHERE o.id = :occurrence_id
AND d.id = :day_id
AND m.archived_at IS NULL
AND o.archived_at IS NULL
LIMIT 1'
);
$marketLinkStmt->execute([
':occurrence_id' => (int) $marketOccurrenceId,
':day_id' => (int) $marketOccurrenceDayId,
]);
if ($marketLinkStmt->fetchColumn() === false) {
throw new InvalidArgumentException('Der Markt oder der Markttag ist nicht aktiv oder nicht miteinander verknüpft.');
}
}
$locations = get_default_location_ids($pdo);
$paymentMethodIds = [];
@@ -170,8 +135,6 @@ try {
':amount_net' => $totalPrice,
':total_amount' => $totalPrice,
':order_date' => $orderDateTime->format('Y-m-d H:i:s'),
':market_occurrence_id' => $marketOccurrenceId === null ? null : (int) $marketOccurrenceId,
':market_occurrence_day_id' => $marketOccurrenceDayId === null ? null : (int) $marketOccurrenceDayId,
]);
$orderId = (int) $order['id'];
@@ -258,16 +221,7 @@ try {
'excelTrigger' => $excelTrigger,
'message' => 'OTC order created successfully',
]);
} catch (InvalidArgumentException $e) {
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
$pdo->rollBack();
}
json_response(422, [
'ok' => false,
'error' => $e->getMessage(),
]);
} catch (Throwable $e) {
} catch (Throwable $e) {
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
$pdo->rollBack();
}
@@ -62,32 +62,6 @@ function shopify_delta_reconciliation_cutoff(PDO $pdo): string
return ($pendingDate < $stateDate ? $pendingDate : $stateDate)->format(DATE_ATOM);
}
function shopify_delta_load_pending_order_gids(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT DISTINCT shopify_order_gid
FROM shopify_webhook_event
WHERE status IN ('received', 'processing', 'failed')
AND shopify_order_gid IS NOT NULL
ORDER BY shopify_order_gid"
);
return array_values(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$stmt->fetchAll(PDO::FETCH_COLUMN)
), static fn (string $value): bool => $value !== ''));
}
function shopify_delta_mark_order_events_processed(PDO $pdo, string $orderGid): void
{
$stmt = $pdo->prepare(
"UPDATE shopify_webhook_event
SET status = 'processed', processed_at = NOW(), error_code = NULL, error_message = NULL
WHERE shopify_order_gid = :shopify_order_gid
AND status IN ('received', 'processing', 'failed')"
);
$stmt->execute([':shopify_order_gid' => $orderGid]);
}
function shopify_delta_store_watermark(PDO $pdo, string $cutoff): void
{
$stmt = $pdo->prepare(
@@ -146,7 +120,7 @@ GQL;
return $orders;
}
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, array $recoveryOrderGids = []): array
function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun): array
{
$cutoffDate = new DateTimeImmutable($cutoff);
$searchQuery = 'created_at:>' . $cutoffDate->format('Y-m-d');
@@ -161,31 +135,14 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, a
'clarification_order_gids' => [],
];
$ordersByGid = [];
foreach (shopify_delta_load_orders($env, $searchQuery) as $summaryOrder) {
$orderGid = trim((string) ($summaryOrder['id'] ?? ''));
if ($orderGid !== '') {
$ordersByGid[$orderGid] = $summaryOrder;
}
}
foreach ($recoveryOrderGids as $recoveryOrderGid) {
if (!isset($ordersByGid[$recoveryOrderGid])) {
$ordersByGid[$recoveryOrderGid] = shopify_load_order_by_gid($env, $recoveryOrderGid);
}
}
$recoverySet = array_fill_keys($recoveryOrderGids, true);
foreach ($ordersByGid as $orderGid => $summaryOrder) {
$createdAt = new DateTimeImmutable((string) ($summaryOrder['createdAt'] ?? ''));
if (!isset($recoverySet[$orderGid]) && $createdAt <= $cutoffDate) {
if ($createdAt <= $cutoffDate) {
continue;
}
$counters['checked_orders']++;
if (($summaryOrder['cancelledAt'] ?? null) !== null || strtoupper((string) ($summaryOrder['displayFinancialStatus'] ?? '')) === 'REFUNDED') {
$counters['skipped_cancelled_or_refunded']++;
if (!$dryRun) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
continue;
}
@@ -209,14 +166,8 @@ function run_shopify_delta(array $env, PDO $pdo, string $cutoff, bool $dryRun, a
}
if (($result['status'] ?? '') === 'already_imported') {
$counters['already_imported']++;
if (!$dryRun && isset($recoverySet[(string) $summaryOrder['id']])) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
} else {
$counters['handed_off']++;
if (!$dryRun) {
shopify_delta_mark_order_events_processed($pdo, (string) $summaryOrder['id']);
}
}
} catch (Throwable $error) {
$counters['status'] = 'partial_success';
@@ -132,21 +132,19 @@ function shopify_webhook_worker_run(PDO $pdo, array $env, int $limit): array
}
}
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$argvLimit = 20;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--limit=')) {
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
}
}
try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env);
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1);
$argvLimit = 20;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--limit=')) {
$argvLimit = max(1, min(100, (int) substr($argument, 8)));
}
}
try {
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
$pdo = connect_database($env);
echo json_encode(shopify_webhook_worker_run($pdo, $env, $argvLimit), JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1);
}
-95
View File
@@ -125,101 +125,6 @@ function get_manufacturer_options(PDO $pdo): array
);
}
function get_party_reference(PDO $pdo, int $partyId): ?array
{
$stmt = $pdo->prepare(
'SELECT id, name, email, phone
FROM party
WHERE id = :id'
);
$stmt->execute([':id' => $partyId]);
$party = $stmt->fetch();
return is_array($party) ? $party : null;
}
function get_contact_reference(PDO $pdo, int $contactId): ?array
{
$stmt = $pdo->prepare(
'SELECT id, party_id, first_name, last_name, email, phone, position_title
FROM contact
WHERE id = :id'
);
$stmt->execute([':id' => $contactId]);
$contact = $stmt->fetch();
return is_array($contact) ? $contact : null;
}
function get_party_options(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT id, name
FROM party
WHERE status = 'active'
ORDER BY name ASC, id ASC"
);
return array_map(
static fn (array $row): array => ['id' => (int) $row['id'], 'name' => (string) ($row['name'] ?? '')],
$stmt->fetchAll()
);
}
function get_contact_options(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT c.id, c.party_id, c.first_name, c.last_name, p.name AS party_name
FROM contact c
INNER JOIN party p ON p.id = c.party_id
WHERE p.status = 'active'
ORDER BY p.name ASC, c.last_name ASC, c.first_name ASC, c.id ASC"
);
return array_map(
static fn (array $row): array => [
'id' => (int) $row['id'],
'party_id' => (int) $row['party_id'],
'first_name' => (string) ($row['first_name'] ?? ''),
'last_name' => (string) ($row['last_name'] ?? ''),
'party_name' => (string) ($row['party_name'] ?? ''),
],
$stmt->fetchAll()
);
}
function update_contact_details(PDO $pdo, int $contactId, array $input): array
{
if ($contactId <= 0) {
throw new InvalidArgumentException('contact_person_id muss positiv sein.');
}
$sets = [];
$params = [':id' => $contactId];
foreach (['email', 'phone'] as $field) {
if (array_key_exists($field, $input)) {
$sets[] = $field . ' = :' . $field;
$value = trim((string) ($input[$field] ?? ''));
$params[':' . $field] = $value !== '' ? $value : null;
}
}
if ($sets === []) {
throw new InvalidArgumentException('Keine änderbaren Kontaktdaten übergeben.');
}
$sets[] = 'updated_at = NOW()';
$stmt = $pdo->prepare(
'UPDATE contact SET ' . implode(', ', $sets) . ' WHERE id = :id
RETURNING id, party_id, first_name, last_name, email, phone, position_title'
);
$stmt->execute($params);
$contact = $stmt->fetch();
if (!is_array($contact)) {
throw new RuntimeException('Kontaktperson nicht gefunden.');
}
return $contact;
}
function create_manufacturer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array
{
$name = trim($name);
+15 -94
View File
@@ -62,57 +62,16 @@ try {
$adjustmentQty,
$outQty
);
$productId = (int) $updatedLot['product_id'];
if (($updatedLot['status'] ?? '') === 'open') {
sync_open_lot_activation_selection($pdo, $productId);
$updatedLot['next_active'] = is_open_lot_selected_for_activation($pdo, (int) $updatedLot['id']);
} else {
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
'Verrechnung nach Pflege der aktiven Charge'
);
}
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
$forecast = get_lager_product_forecast($pdo, $productId);
$currentLot = get_current_lot_snapshot($pdo, $productId);
json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $currentLot, 'balance' => [
'qty_net' => $currentLot['qty_net'],
'qty_in' => $currentLot['qty_in'],
'qty_out' => $currentLot['qty_out'],
], 'forecast' => $forecast]);
}
if ($action === 'delete_open_lot') {
$productId = delete_open_stock_lot(
$pdo,
(int) ($data['lotId'] ?? 0),
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown')
);
sync_open_lot_activation_selection($pdo, $productId);
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
'Verrechnung nach Loeschung einer offenen Charge'
);
json_response(200, ['ok' => true, 'productId' => $productId, 'currentLot' => get_current_lot_snapshot($pdo, $productId), 'availableQty' => get_lager_product_available_qty($pdo, $productId)]);
json_response(200, ['ok' => true, 'lot' => $updatedLot, 'balance' => get_stock_lot_balance($pdo, (int) ($data['lotId'] ?? 0))]);
}
if ($action === 'prepare_lot') {
$productId = (int) ($data['productId'] ?? 0);
$lotNumber = trim((string) ($data['lotNumber'] ?? ''));
if ($lotNumber === '') {
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
}
$lotId = (int) ($data['lotId'] ?? 0);
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId;
$manufacturerLotNumber = trim((string) ($data['manufacturerLotNumber'] ?? ''));
$mfgDate = trim((string) ($data['mfgDate'] ?? ''));
$expiryDate = trim((string) ($data['expiryDate'] ?? ''));
$purchaseDate = trim((string) ($data['purchaseDate'] ?? ''));
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
$targetQty = isset($data['targetQty']) && $data['targetQty'] !== '' ? (float) $data['targetQty'] : null;
$pdo->beginTransaction();
if ($lotId > 0) {
$lotStmt = $pdo->prepare(
"SELECT id
@@ -126,50 +85,26 @@ try {
throw new InvalidArgumentException('Offene Charge nicht gefunden');
}
} else {
$lotId = create_open_lot($pdo, $productId);
$lotId = get_or_create_open_lot($pdo, $productId);
}
$manufacturerPartyId = $data['manufacturerPartyId'] ?? null;
$manufacturerPartyId = $manufacturerPartyId === null || $manufacturerPartyId === '' ? null : (int) $manufacturerPartyId;
$receiptQty = isset($data['receiptQty']) && $data['receiptQty'] !== '' ? (float) $data['receiptQty'] : 0.0;
$preparedLot = update_stock_lot_traceability(
$pdo,
$lotId,
$manufacturerPartyId,
$manufacturerLotNumber,
$mfgDate,
$expiryDate,
$purchaseDate,
isset($data['manufacturerLotNumber']) ? (string) $data['manufacturerLotNumber'] : null,
isset($data['mfgDate']) ? (string) $data['mfgDate'] : null,
isset($data['expiryDate']) ? (string) $data['expiryDate'] : null,
isset($data['purchaseDate']) ? (string) $data['purchaseDate'] : null,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
$receiptQty,
0.0,
0.0,
$lotNumber,
$targetQty,
$lotNumber === ''
$lotNumber
);
sync_open_lot_activation_selection($pdo, $productId);
if (promote_open_lot_when_virtual_shortage_is_current($pdo, $productId, $lotId, (string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'))) {
$preparedLot['status'] = 'current';
$preparedLot['next_active'] = false;
} else {
$preparedLot['next_active'] = is_open_lot_selected_for_activation($pdo, $lotId);
}
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
$forecast = get_lager_product_forecast($pdo, $productId);
$pdo->commit();
json_response(200, ['ok' => true, 'productId' => $productId, 'lot' => $preparedLot, 'balance' => get_stock_lot_balance($pdo, $lotId), 'availableQty' => get_lager_product_available_qty($pdo, $productId), 'forecast' => $forecast]);
}
if ($action === 'select_open_lot') {
$productId = (int) ($data['productId'] ?? 0);
$lotId = (int) ($data['lotId'] ?? 0);
select_open_lot_for_activation($pdo, $productId, $lotId);
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
(string) ($currentUser['username'] ?? $currentUser['email'] ?? 'unknown'),
'Verrechnung nach Auswahl der naechsten aktiven Charge'
);
refresh_lager_stock_lot_forecast_for_product($pdo, $productId);
$currentLot = get_current_lot_snapshot($pdo, $productId);
json_response(200, ['ok' => true, 'productId' => $productId, 'selectedLotId' => $lotId, 'currentLot' => $currentLot, 'forecast' => get_lager_product_forecast($pdo, $productId)]);
json_response(200, ['ok' => true, 'lot' => $preparedLot, 'balance' => get_stock_lot_balance($pdo, $lotId)]);
}
json_response(422, ['ok' => false, 'error' => 'Unsupported action']);
@@ -178,19 +113,5 @@ try {
} catch (InvalidArgumentException $error) {
json_response(422, ['ok' => false, 'error' => $error->getMessage()]);
} catch (Throwable $error) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
try {
system_write_audit_log($pdo, 'lager_api', 'lager-charge.php', 'request_failed', $currentUser['username'] ?? $currentUser['email'] ?? 'unknown', [], [], [
'action' => $action ?? '',
'product_id' => $data['productId'] ?? null,
'lot_id' => $data['lotId'] ?? null,
'exception_class' => get_class($error),
'exception_message' => $error->getMessage(),
]);
} catch (Throwable) {
error_log('lager-charge API failure audit logging failed');
}
json_response(500, ['ok' => false, 'error' => 'Interner Serverfehler']);
json_response(500, ['ok' => false, 'error' => 'Internal server error: ' . $error->getMessage()]);
}
+7 -68
View File
@@ -35,25 +35,10 @@ function lager_forecast_load_current_lots(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id,
COALESCE(v.qty_net, 0) AS physical_qty,
next_sl.id AS next_lot_id,
COALESCE(next_v.qty_net, 0) AS next_physical_qty
COALESCE(v.qty_net, 0) AS physical_qty
FROM product p
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current'
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
LEFT JOIN stock_lot next_sl
ON next_sl.product_id = p.id
AND next_sl.status = 'open'
AND next_sl.next_active = TRUE
AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL
AND next_sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL
AND next_sl.mfg_date IS NOT NULL
AND next_sl.expiry_date IS NOT NULL
AND next_sl.purchase_date IS NOT NULL
LEFT JOIN v_stock_lot_balance next_v
ON next_v.stock_lot_id = next_sl.id
AND next_v.qty_net > 0
WHERE p.status = 'active'
ORDER BY p.id"
);
@@ -64,25 +49,10 @@ function lager_forecast_load_current_lot(PDO $pdo, int $productId): ?array
{
$stmt = $pdo->prepare(
"SELECT p.id AS product_id, p.sku, sl.id AS lot_id,
COALESCE(v.qty_net, 0) AS physical_qty,
next_sl.id AS next_lot_id,
COALESCE(next_v.qty_net, 0) AS next_physical_qty
COALESCE(v.qty_net, 0) AS physical_qty
FROM product p
LEFT JOIN stock_lot sl ON sl.product_id = p.id AND sl.status = 'current'
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
LEFT JOIN stock_lot next_sl
ON next_sl.product_id = p.id
AND next_sl.status = 'open'
AND next_sl.next_active = TRUE
AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL
AND next_sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL
AND next_sl.mfg_date IS NOT NULL
AND next_sl.expiry_date IS NOT NULL
AND next_sl.purchase_date IS NOT NULL
LEFT JOIN v_stock_lot_balance next_v
ON next_v.stock_lot_id = next_sl.id
AND next_v.qty_net > 0
WHERE p.status = 'active' AND p.id = :product_id
LIMIT 1"
);
@@ -124,22 +94,15 @@ function lager_forecast_load_daily_sales(PDO $pdo, DateTimeImmutable $asOfDate,
return $sales;
}
function lager_forecast_calculate(
DateTimeImmutable $asOfDate,
float $physicalQty,
array $dailySales,
?DateTimeImmutable $forecastStartDate = null
): array
function lager_forecast_calculate(DateTimeImmutable $asOfDate, float $physicalQty, array $dailySales): array
{
$firstSaleDate = empty($dailySales) ? null : min(array_keys($dailySales));
$lookbackDays = $firstSaleDate === null
? 0
: $asOfDate->diff(new DateTimeImmutable($firstSaleDate))->days + 1;
$lookbackDays = 365;
$halfLifeDays = 60;
$weightedUnits = 0.0;
$weightTotal = 0.0;
$salesUnits = 0.0;
$salesDays = 0;
$firstSaleDate = null;
$lastSaleDate = null;
for ($age = 0; $age < $lookbackDays; $age++) {
@@ -152,6 +115,7 @@ function lager_forecast_calculate(
if ($units > 0) {
$salesUnits += $units;
$salesDays++;
$firstSaleDate = $dateKey;
$lastSaleDate ??= $dateKey;
}
}
@@ -182,9 +146,8 @@ function lager_forecast_calculate(
? 'high'
: ($salesDays >= 10 ? 'medium' : 'low');
$daysUntilDepleted = (int) ceil($physicalQty / $dailyDemand);
$forecastStartDate ??= $asOfDate;
return [
'forecast_date' => $forecastStartDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'),
'forecast_date' => $asOfDate->modify('+' . $daysUntilDepleted . ' days')->format('Y-m-d'),
'forecast_status' => 'forecast', 'confidence' => $confidence, 'daily_demand' => $dailyDemand,
'sales_units' => $salesUnits, 'sales_days' => $salesDays, 'observed_span_days' => $observedSpanDays,
'lookback_days' => $lookbackDays, 'half_life_days' => $halfLifeDays,
@@ -262,18 +225,6 @@ function refresh_lager_stock_lot_forecast_for_product(PDO $pdo, int $productId):
$dailySales[$productId] ?? []
);
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate);
if ($lot['next_lot_id'] !== null && $forecast['forecast_date'] !== null) {
lager_forecast_persist($pdo, [
'lot_id' => $lot['next_lot_id'],
'product_id' => $productId,
'physical_qty' => $lot['next_physical_qty'],
], lager_forecast_calculate(
$asOfDate,
(float) $lot['next_physical_qty'],
$dailySales[$productId] ?? [],
new DateTimeImmutable($forecast['forecast_date'])
), $asOfDate);
}
}
function run_lager_stock_lot_forecast(PDO $pdo): array
@@ -299,18 +250,6 @@ function run_lager_stock_lot_forecast(PDO $pdo): array
try {
$forecast = lager_forecast_calculate($asOfDate, (float) $lot['physical_qty'], $dailySales[$productId] ?? []);
lager_forecast_persist($pdo, $lot, $forecast, $asOfDate);
if ($lot['next_lot_id'] !== null && $forecast['forecast_date'] !== null) {
lager_forecast_persist($pdo, [
'lot_id' => $lot['next_lot_id'],
'product_id' => $productId,
'physical_qty' => $lot['next_physical_qty'],
], lager_forecast_calculate(
$asOfDate,
(float) $lot['next_physical_qty'],
$dailySales[$productId] ?? [],
new DateTimeImmutable($forecast['forecast_date'])
), $asOfDate);
}
$result['processed_products']++;
} catch (Throwable $error) {
$result['failed_products']++;
-91
View File
@@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/service.php';
function erp_lager_mcp_token(array $env): string
{
$token = env_value('MARKET_MCP_TOKEN', $env);
if ($token === '') {
throw new RuntimeException('MARKET_MCP_TOKEN ist nicht konfiguriert.');
}
return $token;
}
function erp_lager_mcp_authorize(array $env): string
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $header, $matches)) {
throw new RuntimeException('MCP Authorization Bearer Token fehlt.');
}
$token = trim($matches[1]);
if (!hash_equals(erp_lager_mcp_token($env), $token)) {
throw new RuntimeException('MCP Authorization Bearer Token ist ungültig.');
}
return 'hermes';
}
function erp_lager_mcp_tools(): array
{
return [
[
'name' => 'erp_lager.list_products',
'description' => 'Aktive ERP-Lagerprodukte zur Ermittlung der Produkt-IDs lesen',
'inputSchema' => ['type' => 'object', 'properties' => (object) []],
],
[
'name' => 'erp_lager.get_product',
'description' => 'Produkt, aktuelle Lagermenge, Chargen und Ausverkaufsprognose lesen',
'inputSchema' => [
'type' => 'object',
'required' => ['product_id'],
'properties' => [
'product_id' => ['type' => 'integer'],
],
],
],
];
}
function erp_lager_mcp_list_products(PDO $pdo): array
{
return array_map(
static fn (array $product): array => [
'id' => $product['id'],
'name' => $product['name'],
'sku' => $product['sku'],
'uom' => $product['uom'],
],
get_otc_order_form_products($pdo)
);
}
function erp_lager_mcp_get_product(PDO $pdo, int $productId): array
{
if ($productId < 1) {
throw new InvalidArgumentException('product_id muss eine positive Ganzzahl sein.');
}
foreach (get_lager_product_overview($pdo) as $product) {
if ((int) $product['id'] === $productId) {
return $product;
}
}
throw new InvalidArgumentException('Aktives Lagerprodukt nicht gefunden.');
}
function erp_lager_mcp_call(PDO $pdo, string $tool, array $arguments): array
{
return match ($tool) {
'erp_lager.list_products' => erp_lager_mcp_list_products($pdo),
'erp_lager.get_product' => erp_lager_mcp_get_product(
$pdo,
(int) ($arguments['product_id'] ?? 0)
),
default => throw new InvalidArgumentException('Unbekanntes Lager-MCP-Tool: ' . $tool),
};
}
+33 -768
View File
@@ -75,8 +75,7 @@ function get_lager_product_overview(PDO $pdo): array
p.name,
p.sku,
p.uom,
COALESCE(v.qty_net, 0) AS available_qty,
COALESCE(v.qty_net, 0) AS current_qty,
COALESCE(SUM(v.qty_net), 0) AS available_qty,
COALESCE(v.qty_in, 0) AS qty_in,
sl.lot_number,
sl.id AS lot_id,
@@ -88,11 +87,6 @@ function get_lager_product_overview(PDO $pdo): array
sl.purchase_date,
sl.sellout_date,
sl.sellout_forecast_status,
next_sl.id AS next_lot_id,
COALESCE(next_v.qty_net, 0) AS next_qty,
next_sl.sellout_date AS next_sellout_date,
next_sl.sellout_forecast_status AS next_sellout_forecast_status,
next_sl.expiry_date AS next_expiry_date,
COALESCE((
SELECT SUM(a.qty)
FROM sales_order_line_lot_allocation a
@@ -116,25 +110,10 @@ function get_lager_product_overview(PDO $pdo): array
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v
ON v.stock_lot_id = sl.id
LEFT JOIN stock_lot next_sl
ON next_sl.product_id = p.id
AND next_sl.status = 'open'
AND next_sl.next_active = TRUE
AND NULLIF(TRIM(next_sl.lot_number), '') IS NOT NULL
AND next_sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(next_sl.manufacturer_lot_number), '') IS NOT NULL
AND next_sl.mfg_date IS NOT NULL
AND next_sl.expiry_date IS NOT NULL
AND next_sl.purchase_date IS NOT NULL
LEFT JOIN v_stock_lot_balance next_v
ON next_v.stock_lot_id = next_sl.id
AND next_v.qty_net > 0
WHERE p.status = 'active'
GROUP BY p.id, p.name, p.sku, p.uom, sl.id, sl.lot_number, sl.manufacturer_party_id,
sl.manufacturer_lot_number, mp.name, sl.mfg_date, sl.expiry_date, sl.purchase_date,
sl.sellout_date, sl.sellout_forecast_status, v.qty_net, v.qty_in,
next_sl.id, next_v.qty_net, next_sl.sellout_date,
next_sl.sellout_forecast_status, next_sl.expiry_date
sl.sellout_date, sl.sellout_forecast_status, v.qty_in
ORDER BY p.name ASC, p.id ASC"
);
@@ -145,7 +124,6 @@ function get_lager_product_overview(PDO $pdo): array
'sku' => (string) $row['sku'],
'uom' => (string) $row['uom'],
'available_qty' => (float) $row['available_qty'],
'current_qty' => (float) $row['current_qty'],
'qty_in' => (float) $row['qty_in'],
'lot_id' => $row['lot_id'] === null ? null : (int) $row['lot_id'],
'lot_number' => $row['lot_number'],
@@ -157,11 +135,6 @@ function get_lager_product_overview(PDO $pdo): array
'purchase_date' => $row['purchase_date'],
'sellout_date' => $row['sellout_date'],
'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'),
'next_lot_id' => $row['next_lot_id'] === null ? null : (int) $row['next_lot_id'],
'next_qty' => (float) ($row['next_qty'] ?? 0),
'next_sellout_date' => $row['next_sellout_date'],
'next_sellout_forecast_status' => (string) ($row['next_sellout_forecast_status'] ?? 'no_demand'),
'next_expiry_date' => $row['next_expiry_date'],
'virtual_shortage_qty' => (float) ($row['virtual_shortage_qty'] ?? 0),
'virtual_lot_number' => $row['virtual_lot_number'],
],
@@ -224,7 +197,6 @@ function get_lager_product_overview(PDO $pdo): array
sl.mfg_date,
sl.expiry_date,
sl.purchase_date,
sl.next_active,
COALESCE(v.qty_in, 0) AS qty_in,
COALESCE(v.qty_net, 0) AS qty_net
FROM stock_lot sl
@@ -243,10 +215,9 @@ function get_lager_product_overview(PDO $pdo): array
'manufacturer_name' => $openLot['manufacturer_name'],
'mfg_date' => $openLot['mfg_date'],
'expiry_date' => $openLot['expiry_date'],
'purchase_date' => $openLot['purchase_date'],
'qty_in' => (float) $openLot['qty_net'],
'qty_net' => (float) $openLot['qty_net'],
'next_active' => (bool) $openLot['next_active'],
'purchase_date' => $openLot['purchase_date'],
'qty_in' => (float) $openLot['qty_in'],
'qty_net' => (float) $openLot['qty_net'],
];
}
@@ -259,71 +230,6 @@ function get_lager_product_overview(PDO $pdo): array
return $products;
}
function get_lager_product_available_qty(PDO $pdo, int $productId): float
{
$stmt = $pdo->prepare(
"SELECT COALESCE(SUM(v.qty_net), 0)
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status IN ('current', 'open')"
);
$stmt->execute([':product_id' => $productId]);
return (float) $stmt->fetchColumn();
}
function get_lager_product_forecast(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
"SELECT stock_lot.sellout_date, stock_lot.sellout_forecast_status, stock_lot.expiry_date,
next_lot.sellout_date AS next_sellout_date,
next_lot.sellout_forecast_status AS next_sellout_forecast_status,
next_lot.expiry_date AS next_expiry_date
FROM stock_lot
LEFT JOIN LATERAL (
SELECT sl.sellout_date, sl.sellout_forecast_status, sl.expiry_date
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = stock_lot.product_id
AND sl.status = 'open'
AND sl.next_active = TRUE
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
LIMIT 1
) next_lot ON TRUE
WHERE stock_lot.product_id = :product_id
AND stock_lot.status = 'current'
LIMIT 1"
);
$stmt->execute([':product_id' => $productId]);
$forecast = $stmt->fetch();
if (!is_array($forecast)) {
throw new RuntimeException('Keine aktuelle Charge fuer Forecast vorhanden');
}
return [
'date' => $forecast['sellout_date'],
'status' => (string) $forecast['sellout_forecast_status'],
'expiryDate' => $forecast['expiry_date'],
'current' => [
'date' => $forecast['sellout_date'],
'status' => (string) $forecast['sellout_forecast_status'],
'expiryDate' => $forecast['expiry_date'],
],
'next' => $forecast['next_sellout_date'] === null && $forecast['next_expiry_date'] === null
? null
: [
'date' => $forecast['next_sellout_date'],
'status' => (string) ($forecast['next_sellout_forecast_status'] ?? 'no_demand'),
'expiryDate' => $forecast['next_expiry_date'],
],
];
}
function update_stock_lot_traceability(
PDO $pdo,
int $lotId,
@@ -336,9 +242,7 @@ function update_stock_lot_traceability(
float $receiptQty = 0.0,
float $adjustmentQty = 0.0,
float $outQty = 0.0,
?string $lotNumber = null,
?float $targetQty = null,
bool $clearLotNumber = false
?string $lotNumber = null
): array {
if ($lotId <= 0) {
throw new InvalidArgumentException('Chargen-ID fehlt');
@@ -349,19 +253,13 @@ function update_stock_lot_traceability(
$manufacturerLotNumber = null;
}
$lotNumber = $lotNumber !== null ? trim($lotNumber) : null;
if ($lotNumber === '' && !$clearLotNumber) {
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
}
if ($lotNumber === '') {
$lotNumber = null;
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
}
if (!is_finite($receiptQty) || !is_finite($adjustmentQty) || !is_finite($outQty)) {
throw new InvalidArgumentException('Ungueltige Bewegungsmenge');
}
if ($targetQty !== null && (!is_finite($targetQty) || $targetQty < 0)) {
throw new InvalidArgumentException('Ungueltige Chargenmenge');
}
if ($receiptQty < 0 || $outQty < 0) {
throw new InvalidArgumentException('Zugang und Abgang duerfen nicht negativ sein');
}
@@ -409,9 +307,6 @@ function update_stock_lot_traceability(
if ((string) $before['status'] === 'virtual_shortage') {
throw new InvalidArgumentException('Virtuelle Fehlbestandschargen koennen nicht gepflegt werden');
}
if ($targetQty !== null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen mit einer Zielmenge bearbeitet werden');
}
if ($lotNumber !== null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen neu angelegt werden');
}
@@ -436,35 +331,13 @@ function update_stock_lot_traceability(
if ($manufacturerPartyId !== null && $manufacturerLotNumber === null && (string) $before['status'] !== 'open') {
throw new InvalidArgumentException('Hersteller-Chargennummer fehlt');
}
if ($targetQty !== null) {
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(SUM(CASE
WHEN move_type = 'in' THEN qty
WHEN move_type = 'out' THEN -qty
WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty
WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty
ELSE 0
END), 0)
FROM stock_move
WHERE lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => $lotId]);
$quantityDelta = $targetQty - (float) $balanceStmt->fetchColumn();
$receiptQty = $quantityDelta > 0 ? $quantityDelta : 0.0;
$outQty = 0.0;
$adjustmentQty = $quantityDelta < 0 ? $quantityDelta : 0.0;
}
if (($adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current' && $targetQty === null) {
if (($adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current') {
throw new InvalidArgumentException('Korrekturen und Abgaenge koennen nur auf der aktiven Charge gebucht werden');
}
$lotNumberAssignment = $clearLotNumber
? 'NULL'
: ($lotNumber === null ? 'lot_number' : 'COALESCE(:lot_number, lot_number)');
$updateStmt = $pdo->prepare(
"UPDATE stock_lot
SET lot_number = {$lotNumberAssignment},
SET lot_number = COALESCE(:lot_number, lot_number),
manufacturer_party_id = :manufacturer_party_id,
manufacturer_lot_number = :manufacturer_lot_number,
mfg_date = :mfg_date,
@@ -473,23 +346,18 @@ function update_stock_lot_traceability(
updated_at = NOW()
WHERE id = :id"
);
$updateParams = [
$updateStmt->execute([
':lot_number' => $lotNumber,
':manufacturer_party_id' => $manufacturerPartyId,
':manufacturer_lot_number' => $manufacturerLotNumber,
':mfg_date' => $dates['mfg_date'],
':expiry_date' => $dates['expiry_date'],
':purchase_date' => $dates['purchase_date'],
':id' => $lotId,
];
if (!$clearLotNumber && $lotNumber !== null) {
$updateParams[':lot_number'] = $lotNumber;
}
$updateStmt->execute($updateParams);
]);
$after = $before;
if ($clearLotNumber) {
$after['lot_number'] = null;
} elseif ($lotNumber !== null) {
if ($lotNumber !== null) {
$after['lot_number'] = $lotNumber;
}
$after['manufacturer_party_id'] = $manufacturerPartyId;
@@ -502,7 +370,7 @@ function update_stock_lot_traceability(
]);
if ($receiptQty > 0 || $adjustmentQty !== 0.0 || $outQty > 0) {
book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty, $changedBy);
book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty);
}
if ($startedTransaction) {
@@ -518,506 +386,6 @@ function update_stock_lot_traceability(
}
}
function is_qualified_open_lot(PDO $pdo, int $lotId): bool
{
$stmt = $pdo->prepare(
"SELECT EXISTS (
SELECT 1
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.id = :lot_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
)"
);
$stmt->execute([':lot_id' => $lotId]);
return (bool) $stmt->fetchColumn();
}
function is_open_lot_selected_for_activation(PDO $pdo, int $lotId): bool
{
$stmt = $pdo->prepare(
"SELECT next_active
FROM stock_lot
WHERE id = :lot_id
AND status = 'open'"
);
$stmt->execute([':lot_id' => $lotId]);
return (bool) $stmt->fetchColumn();
}
function sync_open_lot_activation_selection(PDO $pdo, int $productId): void
{
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lockStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'open'
FOR UPDATE"
);
$lockStmt->execute([':product_id' => $productId]);
$qualifiedStmt = $pdo->prepare(
"SELECT sl.id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
ORDER BY sl.id"
);
$qualifiedStmt->execute([':product_id' => $productId]);
$qualifiedIds = array_map('intval', $qualifiedStmt->fetchAll(PDO::FETCH_COLUMN));
if (count($qualifiedIds) === 1) {
$clearStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = FALSE, updated_at = NOW()
WHERE product_id = :product_id AND status = 'open'"
);
$clearStmt->execute([':product_id' => $productId]);
$selectStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = TRUE, updated_at = NOW()
WHERE id = :lot_id"
);
$selectStmt->execute([':lot_id' => $qualifiedIds[0]]);
} elseif ($qualifiedIds === []) {
$clearStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = FALSE, updated_at = NOW()
WHERE product_id = :product_id AND status = 'open'"
);
$clearStmt->execute([':product_id' => $productId]);
} else {
$selectedStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'open'
AND next_active = TRUE
LIMIT 1
FOR UPDATE"
);
$selectedStmt->execute([':product_id' => $productId]);
$selectedId = $selectedStmt->fetchColumn();
if ($selectedId === false || !in_array((int) $selectedId, $qualifiedIds, true)) {
$clearStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = FALSE, updated_at = NOW()
WHERE product_id = :product_id AND status = 'open'"
);
$clearStmt->execute([':product_id' => $productId]);
}
}
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function promote_open_lot_when_virtual_shortage_is_current(PDO $pdo, int $productId, int $lotId, string $changedBy): bool
{
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$realCurrentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'current'
LIMIT 1
FOR UPDATE"
);
$realCurrentStmt->execute([':product_id' => $productId]);
$realCurrent = $realCurrentStmt->fetch();
if (is_array($realCurrent)) {
$realCurrentBalanceStmt = $pdo->prepare(
"SELECT COALESCE(qty_net, 0)
FROM v_stock_lot_balance
WHERE stock_lot_id = :lot_id"
);
$realCurrentBalanceStmt->execute([':lot_id' => (int) $realCurrent['id']]);
$realCurrentQty = (float) ($realCurrentBalanceStmt->fetchColumn() ?: 0);
} else {
$realCurrentQty = 0.0;
}
if ($realCurrentQty > 0.0000001) {
if ($startedTransaction) {
$pdo->commit();
}
return false;
}
$currentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'virtual_shortage'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$currentStmt->execute([':product_id' => $productId]);
$virtualShortageLotId = $currentStmt->fetchColumn();
if (!is_qualified_open_lot($pdo, $lotId)) {
if ($startedTransaction) {
$pdo->commit();
}
return false;
}
$lotStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE id = :lot_id
AND product_id = :product_id
AND status = 'open'
FOR UPDATE"
);
$lotStmt->execute([':lot_id' => $lotId, ':product_id' => $productId]);
if ($lotStmt->fetchColumn() === false) {
if ($startedTransaction) {
$pdo->commit();
}
return false;
}
if (is_array($realCurrent)) {
$closeStmt = $pdo->prepare(
"UPDATE stock_lot
SET status = 'closed', updated_at = NOW()
WHERE id = :lot_id"
);
$closeStmt->execute([':lot_id' => (int) $realCurrent['id']]);
}
$promoteStmt = $pdo->prepare(
"UPDATE stock_lot
SET status = 'current', next_active = FALSE, updated_at = NOW()
WHERE id = :lot_id"
);
$promoteStmt->execute([':lot_id' => $lotId]);
if ($virtualShortageLotId !== false) {
reconcile_virtual_shortage_allocations(
$pdo,
(int) $virtualShortageLotId,
$lotId,
$changedBy,
'Automatische Verrechnung bei Aktivierung einer echten Charge'
);
}
if ($startedTransaction) {
$pdo->commit();
}
return true;
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function reconcile_virtual_shortage_allocations(
PDO $pdo,
int $virtualLotId,
int $realLotId,
string $changedBy,
string $reason
): void {
$allocationStmt = $pdo->prepare(
"SELECT id, qty
FROM sales_order_line_lot_allocation
WHERE lot_id = :lot_id
AND allocation_status = 'reserved'
ORDER BY id
FOR UPDATE"
);
$allocationStmt->execute([':lot_id' => $virtualLotId]);
$allocations = $allocationStmt->fetchAll();
foreach ($allocations as $allocation) {
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(qty_net, 0)
FROM v_stock_lot_balance
WHERE stock_lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => $realLotId]);
$availableQty = (float) ($balanceStmt->fetchColumn() ?: 0);
if ($availableQty <= 0.0000001) {
break;
}
$replaceQty = min((float) $allocation['qty'], $availableQty);
replace_virtual_lot_allocation(
$pdo,
(int) $allocation['id'],
$realLotId,
$replaceQty,
$changedBy,
$reason
);
}
$remainingStmt = $pdo->prepare(
"SELECT COUNT(*)
FROM sales_order_line_lot_allocation
WHERE lot_id = :lot_id
AND allocation_status = 'reserved'"
);
$remainingStmt->execute([':lot_id' => $virtualLotId]);
if ((int) $remainingStmt->fetchColumn() !== 0) {
return;
}
$closeStmt = $pdo->prepare(
"UPDATE stock_lot
SET status = 'closed', next_active = FALSE, updated_at = NOW()
WHERE id = :lot_id
AND status = 'virtual_shortage'"
);
$closeStmt->execute([':lot_id' => $virtualLotId]);
if ($closeStmt->rowCount() !== 1) {
throw new RuntimeException('Virtuelle Fehlbestandscharge konnte nicht aufgeloest werden');
}
system_write_audit_log($pdo, 'stock_lot', (string) $virtualLotId, 'closed', $changedBy, [
'status' => 'virtual_shortage',
], [
'status' => 'closed',
], [
'module' => 'erp.lager',
'reason' => $reason,
]);
}
function ensure_current_lot_and_reconcile_virtual_shortage(
PDO $pdo,
int $productId,
string $changedBy,
string $reason
): void {
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$currentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'current'
LIMIT 1
FOR UPDATE"
);
$currentStmt->execute([':product_id' => $productId]);
$currentLotId = $currentStmt->fetchColumn();
if ($currentLotId === false) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(qty_net, 0)
FROM v_stock_lot_balance
WHERE stock_lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => (int) $currentLotId]);
$currentQty = (float) ($balanceStmt->fetchColumn() ?: 0);
if ($currentQty <= 0.0000001) {
sync_open_lot_activation_selection($pdo, $productId);
$selectedStmt = $pdo->prepare(
"SELECT sl.id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'open'
AND sl.next_active = TRUE
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
LIMIT 1"
);
$selectedStmt->execute([':product_id' => $productId]);
$nextLotId = $selectedStmt->fetchColumn();
if ($nextLotId === false) {
$countStmt = $pdo->prepare(
"SELECT COUNT(*), MIN(sl.id)
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0"
);
$countStmt->execute([':product_id' => $productId]);
$candidate = $countStmt->fetch(PDO::FETCH_NUM);
if (is_array($candidate) && (int) $candidate[0] === 1) {
$nextLotId = $candidate[1];
}
}
if ($nextLotId !== false && $nextLotId !== null) {
promote_open_lot_when_virtual_shortage_is_current(
$pdo,
$productId,
(int) $nextLotId,
$changedBy
);
}
}
for ($iteration = 0; $iteration < 100; $iteration++) {
$currentStmt->execute([':product_id' => $productId]);
$currentLotId = $currentStmt->fetchColumn();
if ($currentLotId === false) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
$balanceStmt->execute([':lot_id' => (int) $currentLotId]);
$currentQty = (float) ($balanceStmt->fetchColumn() ?: 0);
if ($currentQty <= 0.0000001) {
break;
}
$virtualStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'virtual_shortage'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$virtualStmt->execute([':product_id' => $productId]);
$virtualLotId = $virtualStmt->fetchColumn();
if ($virtualLotId === false) {
break;
}
reconcile_virtual_shortage_allocations(
$pdo,
(int) $virtualLotId,
(int) $currentLotId,
$changedBy,
$reason
);
$remainingStmt = $pdo->prepare(
"SELECT COUNT(*)
FROM sales_order_line_lot_allocation
WHERE lot_id = :lot_id
AND allocation_status = 'reserved'"
);
$remainingStmt->execute([':lot_id' => (int) $virtualLotId]);
if ((int) $remainingStmt->fetchColumn() === 0) {
break;
}
}
if ($iteration >= 100) {
throw new RuntimeException("Virtuelle Fehlbestandscharge fuer Produkt {$productId} konnte nicht vollstaendig verrechnet werden");
}
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function select_open_lot_for_activation(PDO $pdo, int $productId, int $lotId): void
{
if ($productId <= 0 || $lotId <= 0) {
throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden');
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lotStmt = $pdo->prepare(
"SELECT product_id, status
FROM stock_lot
WHERE id = :lot_id
FOR UPDATE"
);
$lotStmt->execute([':lot_id' => $lotId]);
$lot = $lotStmt->fetch();
if (!is_array($lot) || (int) $lot['product_id'] !== $productId || (string) $lot['status'] !== 'open') {
throw new InvalidArgumentException('Offene Charge nicht gefunden');
}
if (!is_qualified_open_lot($pdo, $lotId)) {
throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden');
}
$clearStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = FALSE, updated_at = NOW()
WHERE product_id = :product_id AND status = 'open'"
);
$clearStmt->execute([':product_id' => $productId]);
$selectStmt = $pdo->prepare(
"UPDATE stock_lot
SET next_active = TRUE, updated_at = NOW()
WHERE id = :lot_id"
);
$selectStmt->execute([':lot_id' => $lotId]);
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function get_current_lot_balance_for_update(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
@@ -1071,46 +439,6 @@ function get_stock_lot_balance(PDO $pdo, int $lotId): array
];
}
function get_current_lot_snapshot(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
"SELECT sl.id, sl.product_id, sl.lot_number, sl.status, sl.next_active,
sl.manufacturer_party_id, sl.manufacturer_lot_number,
mp.name AS manufacturer_name, sl.mfg_date, sl.expiry_date, sl.purchase_date,
COALESCE(v.qty_net, 0) AS qty_net,
COALESCE(v.qty_in, 0) AS qty_in,
COALESCE(v.qty_out, 0) AS qty_out
FROM stock_lot sl
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'current'
LIMIT 1"
);
$stmt->execute([':product_id' => $productId]);
$row = $stmt->fetch();
if (!is_array($row)) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
return [
'id' => (int) $row['id'],
'product_id' => (int) $row['product_id'],
'lot_number' => $row['lot_number'],
'status' => (string) $row['status'],
'next_active' => (bool) $row['next_active'],
'manufacturer_party_id' => $row['manufacturer_party_id'] === null ? null : (int) $row['manufacturer_party_id'],
'manufacturer_lot_number' => $row['manufacturer_lot_number'],
'manufacturer_name' => $row['manufacturer_name'],
'mfg_date' => $row['mfg_date'],
'expiry_date' => $row['expiry_date'],
'purchase_date' => $row['purchase_date'],
'qty_net' => (float) $row['qty_net'],
'qty_in' => (float) $row['qty_in'],
'qty_out' => (float) $row['qty_out'],
];
}
function insert_stock_move_out(
PDO $pdo,
int $productId,
@@ -1219,8 +547,7 @@ function book_stock_lot_movements(
int $lotId,
float $receiptQty,
float $adjustmentQty,
float $outQty,
string $changedBy
float $outQty
): void {
$locations = get_default_location_ids($pdo);
$balanceStmt = $pdo->prepare(
@@ -1255,12 +582,6 @@ function book_stock_lot_movements(
insert_stock_move_adjustment($pdo, $productId, $lotId, $correctionQty, $locations['storage'], null, 'Manuelle Bestandskorrektur via Chargenformular');
}
$balance += $adjustmentQty;
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
$changedBy,
'Verrechnung nach manueller Bestandskorrektur'
);
}
if ($outQty > $balance) {
@@ -1268,12 +589,6 @@ function book_stock_lot_movements(
}
if ($outQty > 0) {
insert_stock_move_out($pdo, $productId, $lotId, $outQty, $locations['storage'], $locations['dispatch'], 'Manueller Lagerabgang via Chargenformular');
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
$changedBy,
'Verrechnung nach manuellem Lagerabgang'
);
}
}
@@ -1425,8 +740,23 @@ function get_or_create_virtual_shortage_lot(PDO $pdo, int $productId): int
return (int) $newLotId;
}
function create_open_lot(PDO $pdo, int $productId): int
function get_or_create_open_lot(PDO $pdo, int $productId): int
{
$select = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'open'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$select->execute([':product_id' => $productId]);
$lotId = $select->fetchColumn();
if ($lotId !== false) {
return (int) $lotId;
}
$insert = $pdo->prepare(
"INSERT INTO stock_lot (product_id, status, created_at, updated_at)
VALUES (:product_id, 'open', NOW(), NOW())
@@ -1441,60 +771,6 @@ function create_open_lot(PDO $pdo, int $productId): int
return (int) $newLotId;
}
function delete_open_stock_lot(PDO $pdo, int $lotId, string $changedBy): int
{
if ($lotId <= 0 || trim($changedBy) === '') {
throw new InvalidArgumentException('Offene Charge und Bediener sind erforderlich');
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$lotStmt = $pdo->prepare(
"SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number,
mfg_date, expiry_date, purchase_date, status
FROM stock_lot
WHERE id = :id
FOR UPDATE"
);
$lotStmt->execute([':id' => $lotId]);
$lot = $lotStmt->fetch();
if (!is_array($lot)) {
throw new InvalidArgumentException('Charge nicht gefunden');
}
if ((string) $lot['status'] !== 'open') {
throw new InvalidArgumentException('Nur offene Chargen koennen geloescht werden');
}
system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'deleted', $changedBy, $lot, [], [
'module' => 'erp.lager',
]);
$moveStmt = $pdo->prepare('DELETE FROM stock_move WHERE lot_id = :lot_id');
$moveStmt->execute([':lot_id' => $lotId]);
$deleteStmt = $pdo->prepare('DELETE FROM stock_lot WHERE id = :id AND status = \'open\'');
$deleteStmt->execute([':id' => $lotId]);
if ($deleteStmt->rowCount() !== 1) {
throw new RuntimeException('Offene Charge konnte nicht geloescht werden');
}
$productId = (int) $lot['product_id'];
if ($startedTransaction) {
$pdo->commit();
}
return $productId;
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function replace_virtual_lot_allocation(
PDO $pdo,
int $allocationId,
@@ -1553,7 +829,8 @@ function replace_virtual_lot_allocation(
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(v.qty_net, 0)
FROM v_stock_lot_balance v
WHERE v.stock_lot_id = :lot_id"
WHERE v.stock_lot_id = :lot_id
FOR UPDATE"
);
$balanceStmt->execute([':lot_id' => $realLotId]);
$available = (float) ($balanceStmt->fetchColumn() ?: 0);
@@ -1676,12 +953,6 @@ function allocate_components_for_line(
throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}");
}
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
'system',
"Verrechnung vor Verkaufsallokation: order={$orderId}:line={$lineNo}"
);
$current = get_current_lot_balance_for_update($pdo, $productId);
$lotId = $current['lot_id'];
$available = $current['qty_net'];
@@ -1703,12 +974,6 @@ function allocate_components_for_line(
(int) $locations['dispatch'],
"{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}"
);
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
'system',
"Verrechnung nach Verkaufsabgang: order={$orderId}:line={$lineNo}"
);
$allocationStatus = 'allocated';
}
-347
View File
@@ -1,347 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/service.php';
function market_management_mcp_token(array $env): string
{
$token = env_value('MARKET_MCP_TOKEN', $env);
if ($token === '') {
throw new RuntimeException('MARKET_MCP_TOKEN ist nicht konfiguriert.');
}
return $token;
}
function market_management_mcp_authorize(array $env): string
{
$header = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? '');
if (!preg_match('/^Bearer\s+(.+)$/i', trim($header), $matches)) {
throw new RuntimeException('Bearer-Token fehlt.');
}
$token = trim($matches[1]);
$expected = market_management_mcp_token($env);
if ($token === '' || !hash_equals($expected, $token)) {
throw new RuntimeException('Bearer-Token ist ungültig.');
}
return 'bearer:' . hash('sha256', $token);
}
function market_management_mcp_tools(): array
{
$idempotencyProperty = ['idempotency_key' => ['type' => 'string', 'minLength' => 1]];
$occurrenceProperties = [
'status' => ['type' => 'string'], 'place' => ['type' => ['string', 'null']], 'address' => ['type' => ['string', 'null']],
'municipality_reference_id' => ['type' => ['integer', 'null']], 'notes' => ['type' => ['string', 'null']],
'registration_deadline' => ['type' => ['string', 'null'], 'format' => 'date'],
'request_or_registration_date' => ['type' => ['string', 'null'], 'format' => 'date'],
'registration_note' => ['type' => ['string', 'null']],
'confirmation_date' => ['type' => ['string', 'null'], 'format' => 'date'],
'payment_deadline' => ['type' => ['string', 'null'], 'format' => 'date'],
'payment_status' => ['type' => ['string', 'null'], 'enum' => ['paid', 'unpaid']],
'payment_date' => ['type' => ['string', 'null'], 'format' => 'date'], 'paid_amount' => ['type' => ['number', 'null']],
'payment_method_code' => ['type' => ['string', 'null']], 'stand_size' => ['type' => ['string', 'null']],
'gross_cost' => ['type' => ['number', 'null'], 'minimum' => 0], 'tax_status' => ['type' => ['string', 'null'], 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => ['number', 'null'], 'minimum' => 0], 'cost_items_text' => ['type' => ['string', 'null']],
'table_available' => ['type' => ['boolean', 'null']], 'table_rentable' => ['type' => ['boolean', 'null']],
'own_table_required' => ['type' => ['boolean', 'null']], 'shelter_available' => ['type' => ['boolean', 'null']],
'power_available' => ['type' => ['boolean', 'null']], 'water_available' => ['type' => ['boolean', 'null']],
'lighting_available' => ['type' => ['boolean', 'null']], 'other_infrastructure' => ['type' => ['string', 'null']],
'own_stand_requirements' => ['type' => ['string', 'null']], 'travel_address' => ['type' => ['string', 'null']],
'travel_distance_km' => ['type' => ['number', 'null']],
'parking_options' => ['type' => ['string', 'null']], 'delivery_access' => ['type' => ['string', 'null']],
'direct_unload_possible' => ['type' => ['boolean', 'null']], 'storage_options' => ['type' => ['string', 'null']],
'travel_notes' => ['type' => ['string', 'null']], 'cancellation_terms' => ['type' => ['string', 'null']],
'revenue_share_terms' => ['type' => ['string', 'null']], 'other_participation_conditions' => ['type' => ['string', 'null']],
'contract_folder_url' => ['type' => ['string', 'null'], 'format' => 'uri'],
'contact_person_name' => ['type' => ['string', 'null']],
'contact_email' => ['type' => ['string', 'null'], 'format' => 'email'], 'contact_phone' => ['type' => ['string', 'null']],
];
$dayProperties = [
'date' => ['type' => 'string', 'format' => 'date'], 'observations' => ['type' => ['string', 'null']],
'stand_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'stand_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
'setup_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'setup_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
'delivery_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'delivery_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
'inherit_from_first_day' => ['type' => ['boolean', 'null']],
];
$searchProperties = [
'search' => ['type' => 'string'], 'market_definition_id' => ['type' => 'integer'], 'category_id' => ['type' => 'integer'],
'status' => ['type' => 'string'], 'place' => ['type' => 'string'], 'address' => ['type' => 'string'],
'municipality_reference_id' => ['type' => 'integer'], 'date_from' => ['type' => 'string', 'format' => 'date'],
'date_to' => ['type' => 'string', 'format' => 'date'], 'include_archived' => ['type' => 'boolean'],
'sort' => ['type' => 'string'], 'sort_direction' => ['type' => 'string', 'enum' => ['asc', 'desc']],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'offset' => ['type' => 'integer', 'minimum' => 0],
];
return [
['name' => 'market_management.search_markets', 'description' => 'Märkte suchen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
['name' => 'market_management.search_occurrences', 'description' => 'Marktdurchführungen suchen und auflisten', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
['name' => 'market_management.get_market', 'description' => 'Markt mit Marktdurchführungen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
['name' => 'market_management.create_market', 'description' => 'Markt mit verpflichtendem Veranstalter, Kategorie und optionaler Website-URL anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id', 'organizer_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null']]]]],
['name' => 'market_management.update_market', 'description' => 'Markt inklusive Veranstalter und Website-URL ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['market_id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null']]]]],
['name' => 'market_management.archive_market', 'description' => 'Markt archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['market_id' => ['type' => 'integer']]]],
['name' => 'market_management.reactivate_market', 'description' => 'Markt reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['market_id' => ['type' => 'integer']]]],
['name' => 'market_management.list_categories', 'description' => 'Bestehende Kategorien lesen', 'inputSchema' => ['type' => 'object', 'properties' => (object) []]],
['name' => 'market_management.list_market_organizers', 'description' => 'Aktive Veranstalter lesen', 'inputSchema' => ['type' => 'object', 'properties' => (object) []]],
['name' => 'market_management.create_market_organizer', 'description' => 'Veranstalter mit optionalen Kontaktdaten anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'idempotency_key'], 'properties' => $idempotencyProperty + ['name' => ['type' => 'string'], 'email' => ['type' => ['string', 'null'], 'format' => 'email'], 'phone' => ['type' => ['string', 'null']]]]],
['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object', 'properties' => (object) []]],
['name' => 'market_management.create_municipality', 'description' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'idempotency_key'], 'properties' => $idempotencyProperty + ['name' => ['type' => 'string']]]],
['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
['name' => 'market_management.create_occurrence', 'description' => 'Marktdurchführung mit mindestens einem Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days', 'idempotency_key'], 'properties' => $idempotencyProperty + ['market_definition_id' => ['type' => 'integer'], 'status' => ['type' => 'string']] + $occurrenceProperties + ['days' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'object', 'required' => ['date'], 'properties' => $dayProperties]]]]],
['name' => 'market_management.get_occurrence', 'description' => 'Marktdurchführung mit Markt, Markttagen und berechneten Verkaufsdaten lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
['name' => 'market_management.get_occurrence_checklist', 'description' => 'Checkliste einer Marktdurchführung lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
['name' => 'market_management.update_occurrence_checklist', 'description' => 'Checkliste einer Marktdurchführung vollständig ersetzen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'items', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer'], 'comment' => ['type' => ['string', 'null']], 'items' => ['type' => 'array', 'items' => ['type' => 'object', 'required' => ['chapter_key', 'label', 'is_completed'], 'properties' => ['chapter_key' => ['type' => 'string', 'enum' => ['products_sales', 'decoration', 'stand_equipment', 'technology']], 'label' => ['type' => 'string'], 'is_completed' => ['type' => 'boolean']]]]]]],
['name' => 'market_management.update_occurrence', 'description' => 'Marktdurchführung ändern', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer']] + $occurrenceProperties]],
['name' => 'market_management.archive_occurrence', 'description' => 'Marktdurchführung archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer']]]],
['name' => 'market_management.reactivate_occurrence', 'description' => 'Marktdurchführung reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer']]]],
['name' => 'market_management.create_day', 'description' => 'Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer']] + $dayProperties]],
['name' => 'market_management.update_day', 'description' => 'Markttag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['day_id' => ['type' => 'integer']] + $dayProperties]],
['name' => 'market_management.delete_day', 'description' => 'Markttag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['day_id' => ['type' => 'integer']]]],
];
}
function market_management_mcp_is_write_tool(string $tool): bool
{
return in_array($tool, [
'market_management.create_market', 'market_management.update_market',
'market_management.create_market_organizer',
'market_management.create_municipality',
'market_management.archive_market', 'market_management.reactivate_market',
'market_management.create_occurrence', 'market_management.update_occurrence',
'market_management.update_occurrence_checklist',
'market_management.archive_occurrence', 'market_management.reactivate_occurrence',
'market_management.create_day', 'market_management.update_day', 'market_management.delete_day',
], true);
}
function market_management_mcp_call_idempotent(PDO $pdo, string $userSubject, string $tool, array $arguments): array
{
if (!market_management_mcp_is_write_tool($tool)) {
return market_management_mcp_call($pdo, $tool, $arguments);
}
$idempotencyKey = trim((string) ($arguments['idempotency_key'] ?? ''));
if ($idempotencyKey === '') {
throw new InvalidArgumentException('Schreibende MCP-Aufrufe benötigen idempotency_key.');
}
$clientId = trim((string) ($arguments['client_id'] ?? 'hermes'));
if ($clientId === '') {
throw new InvalidArgumentException('client_id darf nicht leer sein.');
}
$businessArguments = $arguments;
unset($businessArguments['idempotency_key'], $businessArguments['client_id']);
$requestHash = hash('sha256', json_encode([
'tool' => $tool,
'arguments' => $businessArguments,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$pdo->beginTransaction();
try {
$existingStmt = $pdo->prepare(
'SELECT user_subject, request_hash, result_status, result_payload
FROM market_mcp_idempotency
WHERE client_id = :client_id AND idempotency_key = :idempotency_key
FOR UPDATE'
);
$existingStmt->execute([':client_id' => $clientId, ':idempotency_key' => $idempotencyKey]);
$existing = $existingStmt->fetch();
if (is_array($existing)) {
if ((string) $existing['user_subject'] !== $userSubject || (string) $existing['request_hash'] !== $requestHash) {
throw new InvalidArgumentException('Idempotency-Schlüssel wurde bereits für eine andere Anfrage verwendet.');
}
if ((string) $existing['result_status'] === 'succeeded' && $existing['result_payload'] !== null) {
$pdo->commit();
return (array) json_decode((string) $existing['result_payload'], true, 512, JSON_THROW_ON_ERROR);
}
throw new RuntimeException('Idempotency-Anfrage wird bereits verarbeitet oder ist fehlgeschlagen.');
}
$insert = $pdo->prepare(
'INSERT INTO market_mcp_idempotency
(user_subject, client_id, idempotency_key, tool_name, request_hash, result_status, expires_at)
VALUES (:user_subject, :client_id, :idempotency_key, :tool_name, :request_hash, \'processing\', NOW() + INTERVAL \'24 hours\')'
);
$insert->execute([
':user_subject' => $userSubject,
':client_id' => $clientId,
':idempotency_key' => $idempotencyKey,
':tool_name' => $tool,
':request_hash' => $requestHash,
]);
$result = market_management_mcp_call($pdo, $tool, $businessArguments);
$update = $pdo->prepare(
'UPDATE market_mcp_idempotency
SET result_status = \'succeeded\', result_payload = CAST(:result_payload AS JSONB)
WHERE client_id = :client_id AND idempotency_key = :idempotency_key'
);
$update->execute([
':result_payload' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
':client_id' => $clientId,
':idempotency_key' => $idempotencyKey,
]);
$pdo->commit();
return $result;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function market_management_mcp_get_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): array
{
$occurrence = market_management_get_occurrence($pdo, $occurrenceId, $includeArchived);
if ($occurrence === null) {
throw new RuntimeException('Marktdurchführung nicht gefunden.');
}
$market = market_management_get_market($pdo, (int) $occurrence['market_definition_id'], $includeArchived);
if ($market === null) {
throw new RuntimeException('Markt nicht gefunden.');
}
unset($market['occurrences']);
$occurrence['market'] = $market;
return $occurrence;
}
function market_management_mcp_get_market(PDO $pdo, int $marketId, bool $includeArchived = false): array
{
$market = market_management_get_market($pdo, $marketId, $includeArchived);
if ($market === null) {
throw new RuntimeException('Markt nicht gefunden.');
}
foreach ($market['occurrences'] as $index => $occurrence) {
$enriched = market_management_get_occurrence($pdo, (int) $occurrence['id'], $includeArchived);
if ($enriched !== null) {
$market['occurrences'][$index] = $enriched;
}
}
return $market;
}
function market_management_mcp_update_occurrence(PDO $pdo, int $occurrenceId, array $input): array
{
unset($input['occurrence_id']);
$simpleCostFields = [];
foreach (['gross_cost', 'cost_items_text', 'tax_status', 'tax_rate'] as $field) {
if (array_key_exists($field, $input)) {
$simpleCostFields[$field] = $input[$field];
unset($input[$field]);
}
}
if ($simpleCostFields !== []) {
market_management_update_occurrence_simple_costs($pdo, $occurrenceId, $simpleCostFields);
}
$contactFields = [];
foreach (['contact_person_name', 'contact_email', 'contact_phone'] as $field) {
if (array_key_exists($field, $input)) {
$contactFields[$field] = $input[$field];
unset($input[$field]);
}
}
$existing = null;
if ($contactFields !== []) {
$existing = market_management_get_occurrence($pdo, $occurrenceId, true);
if ($existing === null) {
throw new RuntimeException('Marktdurchführung nicht gefunden.');
}
}
if ($contactFields !== []) {
$contactFields['occurrence_id'] = $occurrenceId;
market_management_set_occurrence_contact($pdo, $contactFields);
}
if ($input !== []) {
market_management_update_occurrence($pdo, $occurrenceId, $input);
}
return market_management_mcp_get_occurrence($pdo, $occurrenceId, true);
}
function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): array
{
return match ($tool) {
'market_management.search_markets' => market_management_search_markets($pdo, $arguments),
'market_management.search_occurrences' => market_management_search_occurrences($pdo, $arguments),
'market_management.get_market' => market_management_mcp_get_market(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
(bool) ($arguments['include_archived'] ?? false)
),
'market_management.create_market' => market_management_create_market($pdo, $arguments),
'market_management.update_market' => market_management_update_market(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
$arguments
),
'market_management.archive_market' => market_management_set_market_archive(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
true
),
'market_management.reactivate_market' => market_management_set_market_archive(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
false
),
'market_management.list_categories' => market_management_list_categories($pdo),
'market_management.list_market_organizers' => market_management_list_organizers($pdo),
'market_management.create_market_organizer' => market_management_create_organizer(
$pdo,
(string) ($arguments['name'] ?? ''),
$arguments['email'] ?? null,
$arguments['phone'] ?? null
),
'market_management.list_reference_data' => market_management_list_reference_data($pdo, $arguments),
'market_management.create_municipality' => market_management_create_municipality(
$pdo,
(string) ($arguments['name'] ?? '')
),
'market_management.get_overview' => [
'markets' => market_management_search_markets($pdo, $arguments),
'occurrences' => market_management_search_occurrences($pdo, $arguments),
],
'market_management.create_occurrence' => (static function () use ($pdo, $arguments): array {
$occurrence = market_management_create_occurrence($pdo, $arguments);
return $occurrence;
})(),
'market_management.get_occurrence' => market_management_mcp_get_occurrence(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
(bool) ($arguments['include_archived'] ?? false)
),
'market_management.get_occurrence_checklist' => market_management_get_occurrence_checklist(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
(bool) ($arguments['include_archived'] ?? false)
),
'market_management.update_occurrence_checklist' => market_management_update_occurrence_checklist(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
$arguments
),
'market_management.update_occurrence' => market_management_mcp_update_occurrence(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
$arguments
),
'market_management.archive_occurrence' => market_management_set_occurrence_archive(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
true
),
'market_management.reactivate_occurrence' => market_management_set_occurrence_archive(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
false
),
'market_management.create_day' => market_management_create_day($pdo, $arguments),
'market_management.update_day' => market_management_update_day(
$pdo,
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id'),
$arguments
),
'market_management.delete_day' => market_management_delete_day(
$pdo,
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id')
),
default => throw new InvalidArgumentException('Unbekanntes MCP-Tool: ' . $tool),
};
}
@@ -1,180 +0,0 @@
<?php
declare(strict_types=1);
const MARKET_OCCURRENCE_CHECKLIST_CHAPTERS = [
[
'key' => 'products_sales',
'label' => 'Produkte & Verkauf',
'items' => ['Fläschchen', 'Säckchen', 'Dunkle Kleber', 'Naurua-Kleber', 'Anwendungsbeilage', 'Flyer Naurua', 'Flyer Vitalpilze', 'Vitalpilzbuch', 'SumUp-Terminal', 'Bargeld'],
],
[
'key' => 'decoration',
'label' => 'Dekoration',
'items' => ['Dekoration', 'Pilze', 'Blumenvase', 'Blume'],
],
[
'key' => 'stand_equipment',
'label' => 'Stand & Ausstattung',
'items' => ['Tisch', 'Tischtücher', 'Beleuchtung', 'Holzklötze', 'Wagen', 'Sonnenschirm', 'Parkkarte/-Marktinformationen'],
],
[
'key' => 'technology',
'label' => 'Technik',
'items' => ['Batterie-/Powerbank fürs Handy'],
],
];
function market_management_checklist_default_items(): array
{
$items = [];
foreach (MARKET_OCCURRENCE_CHECKLIST_CHAPTERS as $chapter) {
foreach ($chapter['items'] as $label) {
$items[] = [
'chapter_key' => $chapter['key'],
'label' => $label,
];
}
}
return $items;
}
function market_management_checklist_assert_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): void
{
$predicate = $includeArchived ? 'TRUE' : 'archived_at IS NULL';
$stmt = $pdo->prepare('SELECT id FROM market_occurrence WHERE id = :id AND ' . $predicate);
$stmt->execute([':id' => $occurrenceId]);
if ($stmt->fetchColumn() === false) {
throw new InvalidArgumentException('occurrence_id verweist nicht auf eine Marktdurchführung.');
}
}
function market_management_checklist_initialize(PDO $pdo, int $occurrenceId): void
{
market_management_checklist_assert_occurrence($pdo, $occurrenceId, true);
$parent = $pdo->prepare(
'INSERT INTO market_occurrence_checklist (occurrence_id)
VALUES (:occurrence_id)
ON CONFLICT (occurrence_id) DO NOTHING'
);
$parent->execute([':occurrence_id' => $occurrenceId]);
$existing = $pdo->prepare('SELECT COUNT(*) FROM market_occurrence_checklist_item WHERE occurrence_id = :occurrence_id');
$existing->execute([':occurrence_id' => $occurrenceId]);
if ((int) $existing->fetchColumn() > 0) {
return;
}
$insert = $pdo->prepare(
'INSERT INTO market_occurrence_checklist_item
(occurrence_id, chapter_key, position, label, is_completed)
VALUES (:occurrence_id, :chapter_key, :position, :label, :is_completed)'
);
$chapterPositions = [];
foreach (market_management_checklist_default_items() as $item) {
$insert->bindValue(':occurrence_id', $occurrenceId, PDO::PARAM_INT);
$insert->bindValue(':chapter_key', $item['chapter_key'], PDO::PARAM_STR);
$chapterPositions[$item['chapter_key']] = ($chapterPositions[$item['chapter_key']] ?? 0) + 1;
$insert->bindValue(':position', $chapterPositions[$item['chapter_key']], PDO::PARAM_INT);
$insert->bindValue(':label', $item['label'], PDO::PARAM_STR);
$insert->bindValue(':is_completed', false, PDO::PARAM_BOOL);
$insert->execute();
}
}
function market_management_get_occurrence_checklist(PDO $pdo, int $occurrenceId, bool $includeArchived = false): array
{
market_management_checklist_assert_occurrence($pdo, $occurrenceId, $includeArchived);
$parent = $pdo->prepare(
'SELECT id, occurrence_id, comment, created_at, updated_at
FROM market_occurrence_checklist
WHERE occurrence_id = :occurrence_id'
);
$parent->execute([':occurrence_id' => $occurrenceId]);
$checklist = $parent->fetch();
if (!is_array($checklist)) {
throw new RuntimeException('Checkliste der Marktdurchführung fehlt.');
}
$items = $pdo->prepare(
'SELECT id, occurrence_id, chapter_key, position, label, is_completed, created_at, updated_at
FROM market_occurrence_checklist_item
WHERE occurrence_id = :occurrence_id
ORDER BY id ASC'
);
$items->execute([':occurrence_id' => $occurrenceId]);
$checklist['items'] = $items->fetchAll();
$checklist['chapters'] = array_map(
static fn (array $chapter): array => ['key' => $chapter['key'], 'label' => $chapter['label']],
MARKET_OCCURRENCE_CHECKLIST_CHAPTERS
);
return $checklist;
}
function market_management_update_occurrence_checklist(PDO $pdo, int $occurrenceId, array $input): array
{
market_management_checklist_assert_occurrence($pdo, $occurrenceId, true);
$items = $input['items'] ?? null;
if (!is_array($items)) {
throw new InvalidArgumentException('items muss eine Liste sein.');
}
$comment = $input['comment'] ?? null;
if ($comment !== null && !is_string($comment)) {
throw new InvalidArgumentException('comment muss ein String oder null sein.');
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$parent = $pdo->prepare(
'INSERT INTO market_occurrence_checklist (occurrence_id, comment)
VALUES (:occurrence_id, :comment)
ON CONFLICT (occurrence_id) DO UPDATE
SET comment = EXCLUDED.comment, updated_at = NOW()'
);
$parent->execute([':occurrence_id' => $occurrenceId, ':comment' => $comment === null ? null : trim($comment)]);
$delete = $pdo->prepare('DELETE FROM market_occurrence_checklist_item WHERE occurrence_id = :occurrence_id');
$delete->execute([':occurrence_id' => $occurrenceId]);
$insert = $pdo->prepare(
'INSERT INTO market_occurrence_checklist_item
(occurrence_id, chapter_key, position, label, is_completed)
VALUES (:occurrence_id, :chapter_key, :position, :label, :is_completed)'
);
$chapterKeys = array_fill_keys(array_column(MARKET_OCCURRENCE_CHECKLIST_CHAPTERS, 'key'), true);
$chapterPositions = [];
foreach (array_values($items) as $position => $item) {
if (!is_array($item)) {
throw new InvalidArgumentException('Jeder Checklistenpunkt muss ein Objekt sein.');
}
$chapterKey = $item['chapter_key'] ?? null;
if (!is_string($chapterKey) || !isset($chapterKeys[$chapterKey])) {
throw new InvalidArgumentException('chapter_key verweist nicht auf ein festes Checklisten-Kapitel.');
}
$label = $item['label'] ?? '';
if (!is_string($label)) {
throw new InvalidArgumentException('Der Text eines Checklistenpunkts muss ein String sein.');
}
$completed = $item['is_completed'] ?? false;
if (!is_bool($completed)) {
throw new InvalidArgumentException('is_completed muss boolean sein.');
}
$insert->bindValue(':occurrence_id', $occurrenceId, PDO::PARAM_INT);
$chapterPositions[$chapterKey] = ($chapterPositions[$chapterKey] ?? 0) + 1;
$insert->bindValue(':chapter_key', $chapterKey, PDO::PARAM_STR);
$insert->bindValue(':position', $chapterPositions[$chapterKey], PDO::PARAM_INT);
$insert->bindValue(':label', trim($label), PDO::PARAM_STR);
$insert->bindValue(':is_completed', $completed, PDO::PARAM_BOOL);
$insert->execute();
}
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
return market_management_get_occurrence_checklist($pdo, $occurrenceId, true);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,7 +10,7 @@ function throttle_webhook_channel(string $channel, int $minIntervalSeconds): voi
$sanitizedChannel = preg_replace('/[^a-z0-9_-]+/i', '_', $channel) ?? 'default';
$lockPath = sys_get_temp_dir() . '/erp_naurua_webhook_throttle_' . $sanitizedChannel . '.lock';
$handle = @fopen($lockPath, 'c+');
$handle = fopen($lockPath, 'c+');
if ($handle === false) {
return;
}
+1 -4
View File
@@ -5,7 +5,6 @@ require_once __DIR__ . '/../shared/db.php';
require_once __DIR__ . '/../erp/lager/forecast.php';
require_once __DIR__ . '/../erp/import-integration/shopify-webhook-worker.php';
require_once __DIR__ . '/../erp/import-integration/shopify-delta-run.php';
require_once __DIR__ . '/../market_management/service.php';
if (PHP_SAPI !== 'cli') {
http_response_code(404);
@@ -27,10 +26,9 @@ try {
'erp.import-integration.shopify_webhook_worker' => static fn (): array => shopify_webhook_worker_run($pdo, $env, 100),
'erp.import-integration.shopify_delta_order_import' => static function () use ($pdo, $env, $reconciliationStartedAt): array {
$cutoff = shopify_delta_reconciliation_cutoff($pdo);
$recoveryOrderGids = shopify_delta_load_pending_order_gids($pdo);
$runId = shopify_delta_start_run($pdo, $cutoff, false);
try {
$result = run_shopify_delta($env, $pdo, $cutoff, false, $recoveryOrderGids);
$result = run_shopify_delta($env, $pdo, $cutoff, false);
$result['technical_run_id'] = $runId;
if (($result['status'] ?? '') === 'done') {
shopify_delta_store_watermark($pdo, $reconciliationStartedAt->format(DATE_ATOM));
@@ -43,7 +41,6 @@ try {
}
},
'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo),
'market_management.occurrence_status_completion' => static fn (): array => market_management_auto_complete_occurrences($pdo),
];
$results = [];
foreach ($jobs as $name => $job) {
-88
View File
@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/erp/lager/mcp.php';
final class ErpLagerMcpJsonRpcException extends RuntimeException
{
public function __construct(private int $rpcCode, string $message, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
public function rpcCode(): int
{
return $this->rpcCode;
}
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
$request = [];
try {
erp_lager_mcp_authorize($env);
$pdo = connect_database($env);
$body = file_get_contents('php://input');
$request = json_decode($body === false ? '' : $body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($request) || ($request['jsonrpc'] ?? '') !== '2.0') {
throw new InvalidArgumentException('Ungültige JSON-RPC-Anfrage.');
}
if (array_key_exists('params', $request) && !is_array($request['params'])) {
throw new ErpLagerMcpJsonRpcException(-32602, 'Parameter müssen als Objekt oder Array übergeben werden.');
}
$id = $request['id'] ?? null;
$method = (string) ($request['method'] ?? '');
$params = is_array($request['params'] ?? null) ? $request['params'] : [];
if ($method === 'initialize') {
$result = [
'protocolVersion' => (string) ($params['protocolVersion'] ?? '2025-03-26'),
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'naurua-erp-lager', 'version' => '1.0.0'],
];
} elseif ($method === 'notifications/initialized') {
http_response_code(202);
exit;
} elseif ($method === 'tools/list') {
$result = ['tools' => erp_lager_mcp_tools()];
} elseif ($method === 'tools/call') {
$tool = (string) ($params['name'] ?? '');
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
if (array_key_exists('arguments', $params) && !is_array($params['arguments'])) {
throw new ErpLagerMcpJsonRpcException(-32602, 'Tool-Argumente müssen als Objekt übergeben werden.');
}
try {
$result = [
'content' => [[
'type' => 'text',
'text' => json_encode(erp_lager_mcp_call($pdo, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
]],
'isError' => false,
];
} catch (InvalidArgumentException $e) {
throw new ErpLagerMcpJsonRpcException(-32602, $e->getMessage(), $e);
}
} else {
throw new ErpLagerMcpJsonRpcException(-32601, 'Unbekannte JSON-RPC-Methode: ' . $method);
}
echo json_encode(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
$isAuthError = $e instanceof RuntimeException && str_contains($e->getMessage(), 'Token');
$isJsonRpcError = $e instanceof ErpLagerMcpJsonRpcException;
http_response_code($isJsonRpcError ? 200 : ($isAuthError ? 401 : 400));
echo json_encode([
'jsonrpc' => '2.0',
'id' => $request['id'] ?? null,
'error' => [
'code' => $isJsonRpcError ? $e->rpcCode() : -32600,
'message' => $e->getMessage(),
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
-84
View File
@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../modules/market_management/service.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
if (auth_current_user($pdo) === null) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Nicht autorisiert.']);
exit;
}
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Methode nicht erlaubt.']);
exit;
}
$csrf = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!auth_validate_csrf_token($csrf)) {
http_response_code(419);
echo json_encode(['ok' => false, 'error' => 'Ungültiges Sicherheits-Token.']);
exit;
}
$payload = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Ungültige Anfrage.']);
exit;
}
try {
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
if (($fields['organizer_id'] ?? '') === '__new__') {
$organizer = market_management_create_organizer(
$pdo,
(string) ($fields['organizer_name'] ?? ''),
(string) ($fields['organizer_email'] ?? ''),
(string) ($fields['organizer_phone'] ?? '')
);
$fields['organizer_id'] = $organizer['id'];
}
if (($payload['action'] ?? 'create') === 'delete') {
$marketId = (int) ($payload['id'] ?? 0);
if ($marketId < 1) {
throw new InvalidArgumentException('id ist erforderlich.');
}
if (($payload['confirmation'] ?? '') !== 'DELETE') {
throw new InvalidArgumentException('Die Löschbestätigung muss exakt DELETE lauten.');
}
$market = market_management_set_market_archive($pdo, $marketId, true);
} elseif (($payload['action'] ?? 'create') === 'update') {
$marketId = (int) ($payload['id'] ?? 0);
if ($marketId < 1) {
throw new InvalidArgumentException('id ist erforderlich.');
}
$market = market_management_update_market($pdo, $marketId, [
'name' => $fields['name'] ?? null,
'category_id' => $fields['category_id'] ?? null,
'organizer_id' => $fields['organizer_id'] ?? null,
'website_url' => $fields['website_url'] ?? null,
]);
} else {
$market = market_management_create_market($pdo, [
'name' => $fields['name'] ?? null,
'category_id' => $fields['category_id'] ?? null,
'organizer_id' => $fields['organizer_id'] ?? null,
'website_url' => $fields['website_url'] ?? null,
]);
}
echo json_encode(['ok' => true, 'market' => $market], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $error) {
http_response_code(422);
echo json_encode(['ok' => false, 'error' => $error->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
-87
View File
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/market_management/mcp.php';
final class MarketManagementMcpJsonRpcException extends RuntimeException
{
public function __construct(private int $rpcCode, string $message, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
public function rpcCode(): int
{
return $this->rpcCode;
}
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
try {
$userSubject = market_management_mcp_authorize($env);
$pdo = connect_database($env);
$body = file_get_contents('php://input');
$request = json_decode($body === false ? '' : $body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($request) || ($request['jsonrpc'] ?? '') !== '2.0') {
throw new InvalidArgumentException('Ungültige JSON-RPC-Anfrage.');
}
if (array_key_exists('params', $request) && !is_array($request['params'])) {
throw new MarketManagementMcpJsonRpcException(-32602, 'Parameter müssen als Objekt oder Array übergeben werden.');
}
$id = $request['id'] ?? null;
$method = (string) ($request['method'] ?? '');
$params = is_array($request['params'] ?? null) ? $request['params'] : [];
if ($method === 'initialize') {
$result = [
'protocolVersion' => (string) ($params['protocolVersion'] ?? '2025-03-26'),
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'naurua-market-management', 'version' => '1.0.0'],
];
} elseif ($method === 'notifications/initialized') {
http_response_code(202);
exit;
} elseif ($method === 'tools/list') {
$result = ['tools' => market_management_mcp_tools()];
} elseif ($method === 'tools/call') {
$tool = (string) ($params['name'] ?? '');
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
if (array_key_exists('arguments', $params) && !is_array($params['arguments'])) {
throw new MarketManagementMcpJsonRpcException(-32602, 'Tool-Argumente müssen als Objekt übergeben werden.');
}
try {
$result = [
'content' => [[
'type' => 'text',
'text' => json_encode(market_management_mcp_call_idempotent($pdo, $userSubject, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
]],
'isError' => false,
];
} catch (InvalidArgumentException $e) {
throw new MarketManagementMcpJsonRpcException(-32602, $e->getMessage(), $e);
}
} else {
throw new MarketManagementMcpJsonRpcException(-32601, 'Unbekannte JSON-RPC-Methode: ' . $method);
}
echo json_encode(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
$isAuthError = $e instanceof RuntimeException && str_contains($e->getMessage(), 'Token');
$isJsonRpcError = $e instanceof MarketManagementMcpJsonRpcException;
http_response_code($isJsonRpcError ? 200 : ($isAuthError ? 401 : 400));
echo json_encode([
'jsonrpc' => '2.0',
'id' => $request['id'] ?? null,
'error' => [
'code' => $isJsonRpcError ? $e->rpcCode() : -32600,
'message' => $e->getMessage(),
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
-147
View File
@@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../modules/erp/kontakte/service.php';
require_once __DIR__ . '/../../modules/market_management/service.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
if (auth_current_user($pdo) === null) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Unauthorized']);
exit;
}
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if ($requestMethod === 'POST') {
$csrf = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!auth_validate_csrf_token($csrf)) {
http_response_code(419);
echo json_encode(['ok' => false, 'error' => 'Ungültiges Sicherheits-Token']);
exit;
}
$payload = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Ungültige Anfrage']);
exit;
}
try {
if (($payload['action'] ?? '') === 'create') {
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
$occurrence = market_management_create_occurrence($pdo, [
'market_definition_id' => $fields['market_definition_id'] ?? null,
'days' => [['date' => $fields['date'] ?? null]],
]);
echo json_encode(['ok' => true, 'occurrence_id' => (int) $occurrence['id']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$scope = (string) ($payload['scope'] ?? '');
$id = market_management_require_positive_id($payload['id'] ?? null, 'id');
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
if (($payload['action'] ?? '') === 'delete') {
if (($payload['confirmation'] ?? '') !== 'DELETE') {
throw new InvalidArgumentException('Die Löschbestätigung muss exakt DELETE lauten.');
}
market_management_set_occurrence_archive($pdo, $id, true);
} elseif (($payload['action'] ?? '') === 'create_day') {
$fields['occurrence_id'] = $id;
$day = market_management_create_day($pdo, $fields);
echo json_encode(['ok' => true, 'day' => $day]);
exit;
} elseif (($payload['action'] ?? '') === 'delete_day') {
if (($payload['confirmation'] ?? '') !== 'DELETE') {
throw new InvalidArgumentException('Bestätigung muss exakt DELETE lauten.');
}
$day = market_management_delete_day($pdo, $id);
echo json_encode(['ok' => true, 'day' => $day]);
exit;
} elseif (($payload['action'] ?? '') === 'update_checklist') {
$checklist = market_management_update_occurrence_checklist($pdo, $id, [
'items' => $payload['items'] ?? null,
'comment' => $payload['comment'] ?? null,
]);
echo json_encode(['ok' => true, 'checklist' => $checklist], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
} elseif ($scope === 'day') {
market_management_update_day($pdo, $id, $fields);
} elseif (in_array($scope, ['occurrence', 'status', 'overview', 'participation', 'infrastructure', 'travel', 'notes'], true)) {
$contactFields = [];
foreach (['contact_person_name', 'contact_email', 'contact_phone'] as $contactField) {
if (array_key_exists($contactField, $fields)) {
$contactFields[$contactField] = $fields[$contactField];
unset($fields[$contactField]);
}
}
$existingOccurrence = null;
if ($contactFields !== []) {
$existingOccurrence = market_management_get_occurrence($pdo, $id, true);
}
if ($contactFields !== []) {
$contactFields['occurrence_id'] = $id;
market_management_set_occurrence_contact($pdo, $contactFields);
}
if ($fields !== []) {
$simpleCostFields = [];
foreach (['gross_cost', 'cost_items_text', 'tax_status', 'tax_rate'] as $costField) {
if (array_key_exists($costField, $fields)) {
$simpleCostFields[$costField] = $fields[$costField];
unset($fields[$costField]);
}
}
if ($simpleCostFields !== []) {
market_management_update_occurrence_simple_costs($pdo, $id, $simpleCostFields);
}
if ($fields !== []) {
market_management_update_occurrence($pdo, $id, $fields);
}
}
} else {
throw new InvalidArgumentException('Ungültiger Bearbeitungsbereich.');
}
echo json_encode(['ok' => true]);
exit;
} catch (Throwable $error) {
http_response_code(422);
echo json_encode(['ok' => false, 'error' => $error->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
}
if (($requestMethod === 'GET') && ($_GET['action'] ?? '') === 'create_options') {
echo json_encode([
'ok' => true,
'market_definitions' => market_management_list_active_market_definitions($pdo),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$occurrenceId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!is_int($occurrenceId) || $occurrenceId <= 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid occurrence id']);
exit;
}
$occurrence = market_management_get_occurrence($pdo, $occurrenceId);
if ($occurrence === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Occurrence not found']);
exit;
}
$market = market_management_get_market($pdo, (int) $occurrence['market_definition_id']);
if ($market === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Market not found']);
exit;
}
echo json_encode(['ok' => true, 'market' => $market, 'occurrence' => $occurrence], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+143 -517
View File
@@ -16,7 +16,6 @@
--color-process-inactive: #FFAE79;
--color-signal-green: #009101;
--color-signal-green-light: #65A665;
--color-signal-green-red: #C0624D;
--color-signal-yellow: #9C7A00;
--color-ampel-hellgelb: #D3B962;
--color-signal-red: #9B3B2F;
@@ -38,7 +37,6 @@
--surface-card-header-muted: var(--color-darkbrown);
--divider-card-segment: var(--color-white);
--text-card-header: var(--color-font-light);
--text-card-signal: var(--color-font-light);
--text-card-body: var(--color-font-dark);
--text-card-transparent: var(--color-font-light);
--layout-card-body-content-justify: flex-start;
@@ -64,7 +62,6 @@
--surface-menu-panel-portal: var(--color-light-grey);
--surface-help-icon: var(--color-medium-grey);
--surface-help-panel: var(--color-light-grey);
--surface-tooltip: var(--color-white);
--surface-pulldown-panel: var(--color-light-grey);
--surface-activatable-remove: var(--surface-control-default);
--surface-slider-track: var(--color-medium-grey);
@@ -79,8 +76,10 @@
--layout-pulldown-chevron-offset: var(--compact-interaction-padding-horizontal);
--layout-tab-navigation-large-padding-inline: var(--interaction-padding-horizontal);
--layout-pulldown-chevron-reserved-space: var(--spacing-large);
--layout-pulldown-panel-form-mobile-width: 90%;
--layer-pulldown-panel: var(--dimension-layer-pulldown-panel);
--layout-multiselect-pulldown-panel-desktop-width: var(--dimension-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-pulldown-label-column-width-fallback: max-content;
--layout-input-label-width: var(--dimension-input-label-width);
--layout-input-field-desktop-width: var(--dimension-input-field-desktop-width);
--layout-input-field-max-width: var(--dimension-input-field-max-width);
@@ -90,8 +89,6 @@
--layout-mode-toggle-local-width-factor: 3;
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
--layout-help-panel-width: calc(var(--interaction-height) * 8);
--layout-tooltip-width: calc(var(--interaction-height) * 8);
--radius-tooltip: var(--radius-interaction);
--text-control-default: var(--color-font-dark);
--text-control-disabled: var(--color-dark-grey);
--text-button-process: var(--color-font-light);
@@ -101,7 +98,6 @@
--text-menu-link-portal: var(--color-font-dark);
--text-help-icon: var(--color-font-dark);
--text-help-panel: var(--color-font-dark);
--text-tooltip: var(--color-font-dark);
--text-activatable-remove: var(--text-control-default);
--text-hyperlink: var(--color-font-hyperlink);
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
@@ -144,6 +140,7 @@
--layout-preview-align-items: flex-start;
/* Semantic pattern tokens: Portal header */
--surface-portal-background: var(--color-background-purple);
--surface-portal-header: var(--color-darkblue);
--surface-portal-header-tab: var(--color-midblue);
--surface-portal-header-tab-active: var(--color-light-grey);
@@ -309,6 +306,7 @@
--dimension-input-label-width: 9rem;
--dimension-input-field-desktop-width: 400px;
--dimension-input-field-max-width: 600px;
--dimension-email-body-max-width: 600px;
--dimension-search-field-width: 15.3rem;
--dimension-public-content-width: 100%;
--dimension-layer-pulldown-panel: 40;
@@ -356,7 +354,7 @@ body {
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
color: var(--color-font-dark);
background: var(--color-background-purple);
background: var(--surface-portal-background);
margin: 0;
padding: var(--spacing-small);
}
@@ -662,13 +660,6 @@ section + section {
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -1762,134 +1753,6 @@ section + section {
right: 0;
}
.sg-tooltip-wrap {
display: contents;
}
.sg-tooltip-panel {
position: absolute;
z-index: var(--layer-pulldown-panel);
display: block;
width: var(--layout-tooltip-width);
max-width: calc(100vw - (2 * var(--spacing-large)));
box-sizing: border-box;
overflow-wrap: anywhere;
padding: var(--spacing-large);
border-radius: var(--radius-tooltip);
color: var(--text-tooltip);
background: var(--surface-tooltip);
box-shadow: var(--shadow-overlay);
}
.sg-tooltip-panel[hidden] {
display: none;
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
top: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
bottom: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
top: 50%;
right: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
top: 50%;
left: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-panel::before {
position: absolute;
width: 0;
height: 0;
border-style: solid;
content: "";
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
top: calc(var(--spacing-small) * -2);
left: 50%;
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
bottom: calc(var(--spacing-small) * -2);
left: 50%;
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
top: 50%;
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
top: 50%;
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"] {
position: fixed;
top: 0;
left: 0;
z-index: var(--layer-pulldown-panel);
max-width: calc(100vw - (2 * var(--spacing-large)));
transform: none;
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before {
top: calc(var(--spacing-small) * -2);
left: var(--tooltip-arrow-offset);
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before {
bottom: calc(var(--spacing-small) * -2);
left: var(--tooltip-arrow-offset);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before {
top: var(--tooltip-arrow-offset);
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before {
top: var(--tooltip-arrow-offset);
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-pulldown-panel {
position: absolute;
top: calc(100% + var(--spacing-small));
@@ -2237,193 +2100,6 @@ section + section {
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
/* ========================================================= */
/* Pattern: History Graph */
/* ========================================================= */
.sg-history-graph {
--layout-history-graph-connector-width: var(--score-marker-width);
--surface-history-graph-connector: var(--chart-marker-line);
width: 100%;
}
.sg-history-graph__events {
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.sg-history-graph__event {
position: relative;
width: 100%;
}
.sg-history-graph__event:not(:last-child)::after {
position: absolute;
top: 100%;
left: 50%;
width: var(--layout-history-graph-connector-width);
height: var(--spacing-large);
background: var(--surface-history-graph-connector);
content: "";
transform: translateX(-50%);
}
.sg-history-graph__card {
width: 100%;
max-width: var(--layout-object-card-min-width);
margin-inline: auto;
}
.sg-history-graph__navigation {
width: 100%;
}
/* ========================================================= */
/* Patterns: Portal Header */
/* ========================================================= */
@@ -2625,44 +2301,19 @@ section + section {
.sg-options-row--left-only .sg-options-row__left {
order: 0;
width: 100%;
padding: 0;
box-shadow: none;
}
.sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
.sg-options-row__left > .sg-pulldown-demo,
.sg-options-row__left > .sg-input-single-line-wrap,
.sg-options-row__left > .sg-search-field-row {
flex: 1 1 calc(50% - var(--layout-options-row-group-gap));
min-width: 0;
}
.sg-options-row__left:has(> .sg-pulldown-demo ~ .sg-pulldown-demo) > .sg-pulldown-demo {
flex-basis: calc(50% - (var(--layout-options-row-group-gap) / 2));
}
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:nth-last-child(2):nth-child(odd)) > .sg-pulldown-demo:nth-last-child(2),
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:last-child:nth-child(odd)) > .sg-pulldown-demo:last-child {
flex-basis: 100%;
}
.sg-options-row--left-only .sg-options-row__left > .sg-pulldown-demo {
flex-basis: 100%;
}
.sg-options-row__left > .sg-search-field-row {
flex: 0 0 100%;
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row > .sg-search-field-input {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row .sg-input-single-line {
width: 100%;
min-width: 0;
flex: 0 0 auto;
}
.sg-options-row__left > .sg-input-single-line-wrap .sg-input-single-line,
@@ -2670,6 +2321,15 @@ section + section {
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-pulldown-panel-form-mobile-width),
calc(100vw - (2 * var(--spacing-large)))
);
min-width: 0;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
@@ -2985,63 +2645,39 @@ section + section {
padding-left: var(--layout-form-sections-interaction-indent);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] {
--layout-multiselect-panel-width: var(--layout-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-panel-available-width: calc(100vw - (2 * var(--spacing-large)));
--layout-multiselect-chapter-gap: var(--spacing-large);
--layout-multiselect-column-gap: var(--spacing-large);
--layout-multiselect-control-gap: var(--spacing-small);
--layout-multiselect-visual-control-gap: calc(var(--spacing-small) * 2);
--layout-multiselect-control-row-height: var(--interaction-height);
--layout-multiselect-value-min-width: calc(var(--interaction-height) * 1.25);
--typography-multiselect-label-line-height: var(--line-height-base);
--opacity-multiselect-disabled: var(--disabled-opacity);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > :not(.sg-form-sections-card__chapter-title) + :not(.sg-form-sections-card__chapter-title) {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > * + * {
margin-top: var(--layout-multiselect-chapter-gap);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-form-sections-card__chapter-title + * {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-slider-row + .sg-slider-row,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-checkbox-field-option + .sg-checkbox-field-option,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-radio-activatable-group + .sg-radio-activatable-group {
margin-top: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo .sg-pulldown {
width: 100%;
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-pulldown-panel__row + .sg-pulldown-panel__row {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
min-width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
max-width: var(--layout-multiselect-panel-available-width);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
width: 100%;
max-width: 100%;
--sg-multiselect-label-column-width: var(--layout-multiselect-pulldown-label-column-width-fallback);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] .sg-pulldown-panel__label {
white-space: normal;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) auto;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr) auto;
align-items: center;
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-control-gap);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable {
@@ -3049,43 +2685,37 @@ section + section {
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider-value,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
grid-column: 3;
min-width: 0;
max-width: 100%;
width: 100%;
max-width: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
grid-column: 4;
margin: 0;
min-width: var(--layout-multiselect-value-min-width);
min-width: 0;
line-height: 1;
align-self: center;
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
@@ -3094,26 +2724,22 @@ section + section {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
align-items: center;
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-visual-control-gap);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
margin: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 3;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
@@ -3122,45 +2748,119 @@ section + section {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > :not(.sg-mode-toggle) {
pointer-events: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--layout-multiselect-control-row-height) auto;
row-gap: var(--layout-multiselect-visual-control-gap);
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header {
display: flex;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
align-items: center;
gap: var(--layout-multiselect-column-gap);
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label {
margin: 0;
min-width: 0;
line-height: var(--typography-multiselect-label-line-height);
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1;
grid-row: 2;
grid-column: 3;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled {
opacity: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-demo .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
@media (max-width: 48rem) {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
grid-template-columns: max-content minmax(0, 1fr) auto;
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1 / -1;
grid-row: 2;
}
}
.sg-form-sections-card__actions {
@@ -4367,27 +4067,22 @@ main {
.sg-card-segment--signal-red {
background: var(--color-signal-red);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-green {
background: var(--color-signal-green);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-green-light {
background: var(--color-signal-green-light);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-red {
background: var(--color-signal-green-red);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-yellow {
background: var(--color-signal-yellow);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--gray {
@@ -4407,11 +4102,6 @@ main {
--text-card-body: var(--color-font-light);
}
.sg-card.sg-card--notification-signal-green-red {
--text-card-header: var(--color-font-light);
--text-card-body: var(--color-font-light);
}
.sg-card--content-card {
--surface-card-header-primary: var(--surface-content-block-card-title);
--surface-card-body: var(--surface-content-block-card-content);
@@ -4900,32 +4590,16 @@ main {
width: 100%;
}
.sg-large-table[data-column-resize="true"] {
overflow: hidden;
}
.sg-large-table__viewport {
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
width: auto;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
}
.sg-large-table__title-segment {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
box-sizing: border-box;
}
.sg-large-table__row {
display: grid;
grid-template-columns: var(--large-table-grid-template-columns, repeat(5, minmax(0, 1fr)));
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: stretch;
gap: 0;
width: 100%;
@@ -4933,11 +4607,6 @@ main {
background: var(--color-white);
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row,
.sg-large-table[data-column-resize="true"] .sg-large-table__title-segment {
width: max(100%, var(--large-table-grid-min-width, 0px));
}
.sg-large-table__row--hidden {
display: none;
}
@@ -4969,59 +4638,16 @@ main {
white-space: normal;
}
.sg-large-table__row--load-more,
.sg-large-table__cell--load-more,
.sg-large-table__load-more-card {
min-width: 0;
.sg-large-table__load-more-layout {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row--load-more {
grid-template-columns: minmax(0, 1fr);
}
.sg-large-table__cell--header {
position: relative;
padding: 0;
}
.sg-large-table__resize-handle {
position: absolute;
z-index: 1;
top: 0;
right: -0.25rem;
bottom: 0;
width: 0.5rem;
cursor: col-resize;
touch-action: none;
}
.sg-large-table__resize-handle::after {
content: "";
position: absolute;
top: 25%;
bottom: 25%;
left: 50%;
width: 0.1rem;
transform: translateX(-50%);
background: var(--color-darkblue);
box-shadow: -0.15rem 0 0 var(--color-darkblue), 0.15rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:hover::after,
.sg-large-table__resize-handle:focus-visible::after,
.sg-large-table[data-column-resize-state="resizing"] .sg-large-table__resize-handle[data-large-table-resize-active="true"]::after {
background: var(--color-darkblue);
box-shadow: -0.2rem 0 0 var(--color-darkblue), 0.2rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:focus-visible {
outline: 2px solid var(--color-darkblue);
outline-offset: -1px;
}
.sg-large-table__sort-button {
display: flex;
align-items: center;
+3 -3
View File
@@ -1,7 +1,7 @@
{
"styleguideVersion": "2026.05.18.2",
"styleguideCommit": "08fc673",
"syncedAtUtc": "2026-10-14T11:46:02Z",
"styleguideVersion": "2026.05.18.1",
"styleguideCommit": "fd73842",
"syncedAtUtc": "2026-09-06T13:32:07Z",
"sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide",
"mirroredDocsPath": "docs/styleguide"
}
+143 -517
View File
@@ -16,7 +16,6 @@
--color-process-inactive: #FFAE79;
--color-signal-green: #009101;
--color-signal-green-light: #65A665;
--color-signal-green-red: #C0624D;
--color-signal-yellow: #9C7A00;
--color-ampel-hellgelb: #D3B962;
--color-signal-red: #9B3B2F;
@@ -38,7 +37,6 @@
--surface-card-header-muted: var(--color-darkbrown);
--divider-card-segment: var(--color-white);
--text-card-header: var(--color-font-light);
--text-card-signal: var(--color-font-light);
--text-card-body: var(--color-font-dark);
--text-card-transparent: var(--color-font-light);
--layout-card-body-content-justify: flex-start;
@@ -64,7 +62,6 @@
--surface-menu-panel-portal: var(--color-light-grey);
--surface-help-icon: var(--color-medium-grey);
--surface-help-panel: var(--color-light-grey);
--surface-tooltip: var(--color-white);
--surface-pulldown-panel: var(--color-light-grey);
--surface-activatable-remove: var(--surface-control-default);
--surface-slider-track: var(--color-medium-grey);
@@ -79,8 +76,10 @@
--layout-pulldown-chevron-offset: var(--compact-interaction-padding-horizontal);
--layout-tab-navigation-large-padding-inline: var(--interaction-padding-horizontal);
--layout-pulldown-chevron-reserved-space: var(--spacing-large);
--layout-pulldown-panel-form-mobile-width: 90%;
--layer-pulldown-panel: var(--dimension-layer-pulldown-panel);
--layout-multiselect-pulldown-panel-desktop-width: var(--dimension-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-pulldown-label-column-width-fallback: max-content;
--layout-input-label-width: var(--dimension-input-label-width);
--layout-input-field-desktop-width: var(--dimension-input-field-desktop-width);
--layout-input-field-max-width: var(--dimension-input-field-max-width);
@@ -90,8 +89,6 @@
--layout-mode-toggle-local-width-factor: 3;
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
--layout-help-panel-width: calc(var(--interaction-height) * 8);
--layout-tooltip-width: calc(var(--interaction-height) * 8);
--radius-tooltip: var(--radius-interaction);
--text-control-default: var(--color-font-dark);
--text-control-disabled: var(--color-dark-grey);
--text-button-process: var(--color-font-light);
@@ -101,7 +98,6 @@
--text-menu-link-portal: var(--color-font-dark);
--text-help-icon: var(--color-font-dark);
--text-help-panel: var(--color-font-dark);
--text-tooltip: var(--color-font-dark);
--text-activatable-remove: var(--text-control-default);
--text-hyperlink: var(--color-font-hyperlink);
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
@@ -144,6 +140,7 @@
--layout-preview-align-items: flex-start;
/* Semantic pattern tokens: Portal header */
--surface-portal-background: var(--color-background-purple);
--surface-portal-header: var(--color-darkblue);
--surface-portal-header-tab: var(--color-midblue);
--surface-portal-header-tab-active: var(--color-light-grey);
@@ -309,6 +306,7 @@
--dimension-input-label-width: 9rem;
--dimension-input-field-desktop-width: 400px;
--dimension-input-field-max-width: 600px;
--dimension-email-body-max-width: 600px;
--dimension-search-field-width: 15.3rem;
--dimension-public-content-width: 100%;
--dimension-layer-pulldown-panel: 40;
@@ -356,7 +354,7 @@ body {
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
color: var(--color-font-dark);
background: var(--color-background-purple);
background: var(--surface-portal-background);
margin: 0;
padding: var(--spacing-small);
}
@@ -662,13 +660,6 @@ section + section {
color: var(--text-control-default);
}
.sg-button--warning {
--surface-button-warning: var(--color-signal-red);
--text-button-warning: var(--color-font-light);
background: var(--surface-button-warning);
color: var(--text-button-warning);
}
.sg-button--process {
background: var(--surface-button-process);
color: var(--text-button-process);
@@ -1762,134 +1753,6 @@ section + section {
right: 0;
}
.sg-tooltip-wrap {
display: contents;
}
.sg-tooltip-panel {
position: absolute;
z-index: var(--layer-pulldown-panel);
display: block;
width: var(--layout-tooltip-width);
max-width: calc(100vw - (2 * var(--spacing-large)));
box-sizing: border-box;
overflow-wrap: anywhere;
padding: var(--spacing-large);
border-radius: var(--radius-tooltip);
color: var(--text-tooltip);
background: var(--surface-tooltip);
box-shadow: var(--shadow-overlay);
}
.sg-tooltip-panel[hidden] {
display: none;
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
top: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
bottom: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
top: 50%;
right: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
top: 50%;
left: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-panel::before {
position: absolute;
width: 0;
height: 0;
border-style: solid;
content: "";
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
top: calc(var(--spacing-small) * -2);
left: 50%;
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
bottom: calc(var(--spacing-small) * -2);
left: 50%;
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
top: 50%;
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
top: 50%;
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"] {
position: fixed;
top: 0;
left: 0;
z-index: var(--layer-pulldown-panel);
max-width: calc(100vw - (2 * var(--spacing-large)));
transform: none;
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before {
top: calc(var(--spacing-small) * -2);
left: var(--tooltip-arrow-offset);
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before {
bottom: calc(var(--spacing-small) * -2);
left: var(--tooltip-arrow-offset);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before {
top: var(--tooltip-arrow-offset);
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before {
top: var(--tooltip-arrow-offset);
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-pulldown-panel {
position: absolute;
top: calc(100% + var(--spacing-small));
@@ -2237,193 +2100,6 @@ section + section {
text-decoration: none;
}
/* Component: File Input */
.sg-file-input {
width: 100%;
}
.sg-file-input__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
}
.sg-file-input__control {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sg-file-input:focus-within .sg-file-input__trigger {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-input__trigger {
align-self: flex-start;
}
@media (max-width: 48rem) {
.sg-file-input__row {
align-items: flex-start;
flex-direction: column;
}
}
.sg-file-input--disabled {
opacity: var(--disabled-opacity);
}
.sg-file-input--disabled .sg-file-input__trigger {
cursor: not-allowed;
}
/* ========================================================= */
/* Patterns: Datei-Upload */
/* ========================================================= */
.sg-file-upload {
--surface-file-upload-dropzone: var(--surface-form-preview);
--surface-file-upload-file-item: var(--surface-control-default);
--text-file-upload-meta: var(--text-control-disabled);
--text-file-upload-error: var(--color-signal-red);
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
max-width: var(--layout-input-field-max-width);
}
.sg-file-upload__status,
.sg-file-upload__file-meta {
margin: var(--spacing-small) 0 0;
}
.sg-file-upload__dropzone {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
width: 100%;
padding: var(--spacing-large);
background: var(--surface-file-upload-dropzone);
border: 1px dashed var(--surface-button-active);
border-radius: var(--radius-interaction);
}
.sg-file-upload__dropzone[data-drag-over="true"] {
outline: 2px solid var(--text-hyperlink);
outline-offset: 2px;
}
.sg-file-upload__files {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
margin: 0;
padding: 0;
list-style: none;
}
.sg-file-upload__file {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
padding: var(--spacing-small) var(--spacing-large);
color: var(--text-control-default);
background: var(--surface-file-upload-file-item);
border-radius: var(--radius-interaction);
}
.sg-file-upload__file-name {
min-width: 0;
overflow-wrap: anywhere;
}
.sg-file-upload__file-meta {
color: var(--text-file-upload-meta);
}
.sg-file-upload__remove {
flex: 0 0 auto;
}
.sg-file-upload__submit {
align-self: flex-end;
}
.sg-file-upload__error {
color: var(--text-file-upload-error);
}
@media (max-width: 48rem) {
.sg-file-upload__dropzone {
align-items: flex-start;
flex-direction: column;
}
.sg-file-upload__file {
align-items: flex-start;
flex-direction: column;
gap: var(--spacing-small);
}
}
/* ========================================================= */
/* Pattern: History Graph */
/* ========================================================= */
.sg-history-graph {
--layout-history-graph-connector-width: var(--score-marker-width);
--surface-history-graph-connector: var(--chart-marker-line);
width: 100%;
}
.sg-history-graph__events {
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.sg-history-graph__event {
position: relative;
width: 100%;
}
.sg-history-graph__event:not(:last-child)::after {
position: absolute;
top: 100%;
left: 50%;
width: var(--layout-history-graph-connector-width);
height: var(--spacing-large);
background: var(--surface-history-graph-connector);
content: "";
transform: translateX(-50%);
}
.sg-history-graph__card {
width: 100%;
max-width: var(--layout-object-card-min-width);
margin-inline: auto;
}
.sg-history-graph__navigation {
width: 100%;
}
/* ========================================================= */
/* Patterns: Portal Header */
/* ========================================================= */
@@ -2625,44 +2301,19 @@ section + section {
.sg-options-row--left-only .sg-options-row__left {
order: 0;
width: 100%;
padding: 0;
box-shadow: none;
}
.sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
.sg-options-row__left > .sg-pulldown-demo,
.sg-options-row__left > .sg-input-single-line-wrap,
.sg-options-row__left > .sg-search-field-row {
flex: 1 1 calc(50% - var(--layout-options-row-group-gap));
min-width: 0;
}
.sg-options-row__left:has(> .sg-pulldown-demo ~ .sg-pulldown-demo) > .sg-pulldown-demo {
flex-basis: calc(50% - (var(--layout-options-row-group-gap) / 2));
}
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:nth-last-child(2):nth-child(odd)) > .sg-pulldown-demo:nth-last-child(2),
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:last-child:nth-child(odd)) > .sg-pulldown-demo:last-child {
flex-basis: 100%;
}
.sg-options-row--left-only .sg-options-row__left > .sg-pulldown-demo {
flex-basis: 100%;
}
.sg-options-row__left > .sg-search-field-row {
flex: 0 0 100%;
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row > .sg-search-field-input {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row .sg-input-single-line {
width: 100%;
min-width: 0;
flex: 0 0 auto;
}
.sg-options-row__left > .sg-input-single-line-wrap .sg-input-single-line,
@@ -2670,6 +2321,15 @@ section + section {
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-pulldown-panel-form-mobile-width),
calc(100vw - (2 * var(--spacing-large)))
);
min-width: 0;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
@@ -2985,63 +2645,39 @@ section + section {
padding-left: var(--layout-form-sections-interaction-indent);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] {
--layout-multiselect-panel-width: var(--layout-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-panel-available-width: calc(100vw - (2 * var(--spacing-large)));
--layout-multiselect-chapter-gap: var(--spacing-large);
--layout-multiselect-column-gap: var(--spacing-large);
--layout-multiselect-control-gap: var(--spacing-small);
--layout-multiselect-visual-control-gap: calc(var(--spacing-small) * 2);
--layout-multiselect-control-row-height: var(--interaction-height);
--layout-multiselect-value-min-width: calc(var(--interaction-height) * 1.25);
--typography-multiselect-label-line-height: var(--line-height-base);
--opacity-multiselect-disabled: var(--disabled-opacity);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > :not(.sg-form-sections-card__chapter-title) + :not(.sg-form-sections-card__chapter-title) {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > * + * {
margin-top: var(--layout-multiselect-chapter-gap);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-form-sections-card__chapter-title + * {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-slider-row + .sg-slider-row,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-checkbox-field-option + .sg-checkbox-field-option,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-radio-activatable-group + .sg-radio-activatable-group {
margin-top: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo .sg-pulldown {
width: 100%;
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-pulldown-panel__row + .sg-pulldown-panel__row {
margin-top: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
min-width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
max-width: var(--layout-multiselect-panel-available-width);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
width: 100%;
max-width: 100%;
--sg-multiselect-label-column-width: var(--layout-multiselect-pulldown-label-column-width-fallback);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] .sg-pulldown-panel__label {
white-space: normal;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) auto;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr) auto;
align-items: center;
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-control-gap);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable {
@@ -3049,43 +2685,37 @@ section + section {
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider-value,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
grid-column: 3;
min-width: 0;
max-width: 100%;
width: 100%;
max-width: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
grid-column: 4;
margin: 0;
min-width: var(--layout-multiselect-value-min-width);
min-width: 0;
line-height: 1;
align-self: center;
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
@@ -3094,26 +2724,22 @@ section + section {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
align-items: center;
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-visual-control-gap);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
margin: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 3;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
@@ -3122,45 +2748,119 @@ section + section {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > :not(.sg-mode-toggle) {
pointer-events: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--layout-multiselect-control-row-height) auto;
row-gap: var(--layout-multiselect-visual-control-gap);
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
column-gap: var(--spacing-large);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header {
display: flex;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
align-items: center;
gap: var(--layout-multiselect-column-gap);
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label {
margin: 0;
min-width: 0;
line-height: var(--typography-multiselect-label-line-height);
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1;
grid-row: 2;
grid-column: 3;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled {
opacity: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
opacity: var(--opacity-multiselect-disabled);
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-demo .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
@media (max-width: 48rem) {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
grid-template-columns: max-content minmax(0, 1fr) auto;
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1 / -1;
grid-row: 2;
}
}
.sg-form-sections-card__actions {
@@ -4367,27 +4067,22 @@ main {
.sg-card-segment--signal-red {
background: var(--color-signal-red);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-green {
background: var(--color-signal-green);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-green-light {
background: var(--color-signal-green-light);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-red {
background: var(--color-signal-green-red);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--signal-yellow {
background: var(--color-signal-yellow);
color: var(--text-card-signal);
color: var(--text-card-header);
}
.sg-card-segment--gray {
@@ -4407,11 +4102,6 @@ main {
--text-card-body: var(--color-font-light);
}
.sg-card.sg-card--notification-signal-green-red {
--text-card-header: var(--color-font-light);
--text-card-body: var(--color-font-light);
}
.sg-card--content-card {
--surface-card-header-primary: var(--surface-content-block-card-title);
--surface-card-body: var(--surface-content-block-card-content);
@@ -4900,32 +4590,16 @@ main {
width: 100%;
}
.sg-large-table[data-column-resize="true"] {
overflow: hidden;
}
.sg-large-table__viewport {
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
width: auto;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
}
.sg-large-table__title-segment {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
box-sizing: border-box;
}
.sg-large-table__row {
display: grid;
grid-template-columns: var(--large-table-grid-template-columns, repeat(5, minmax(0, 1fr)));
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: stretch;
gap: 0;
width: 100%;
@@ -4933,11 +4607,6 @@ main {
background: var(--color-white);
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row,
.sg-large-table[data-column-resize="true"] .sg-large-table__title-segment {
width: max(100%, var(--large-table-grid-min-width, 0px));
}
.sg-large-table__row--hidden {
display: none;
}
@@ -4969,59 +4638,16 @@ main {
white-space: normal;
}
.sg-large-table__row--load-more,
.sg-large-table__cell--load-more,
.sg-large-table__load-more-card {
min-width: 0;
.sg-large-table__load-more-layout {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row--load-more {
grid-template-columns: minmax(0, 1fr);
}
.sg-large-table__cell--header {
position: relative;
padding: 0;
}
.sg-large-table__resize-handle {
position: absolute;
z-index: 1;
top: 0;
right: -0.25rem;
bottom: 0;
width: 0.5rem;
cursor: col-resize;
touch-action: none;
}
.sg-large-table__resize-handle::after {
content: "";
position: absolute;
top: 25%;
bottom: 25%;
left: 50%;
width: 0.1rem;
transform: translateX(-50%);
background: var(--color-darkblue);
box-shadow: -0.15rem 0 0 var(--color-darkblue), 0.15rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:hover::after,
.sg-large-table__resize-handle:focus-visible::after,
.sg-large-table[data-column-resize-state="resizing"] .sg-large-table__resize-handle[data-large-table-resize-active="true"]::after {
background: var(--color-darkblue);
box-shadow: -0.2rem 0 0 var(--color-darkblue), 0.2rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:focus-visible {
outline: 2px solid var(--color-darkblue);
outline-offset: -1px;
}
.sg-large-table__sort-button {
display: flex;
align-items: center;
+8 -294
View File
@@ -39,102 +39,6 @@ body.sg-card-list-page-drawer-open {
overflow: hidden;
}
.sg-market-mobile-detail-host {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
gap: var(--spacing-large);
padding: var(--spacing-small);
box-sizing: border-box;
overflow: auto;
background: var(--color-background-purple);
}
.sg-market-definition-mobile-detail-host {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
gap: var(--spacing-large);
padding: var(--spacing-small);
box-sizing: border-box;
overflow: auto;
background: var(--color-background-purple);
}
.sg-market-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
.sg-market-definition-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
body.sg-market-mobile-detail-open > :not([data-market-mobile-detail-host]) {
display: none !important;
}
body.sg-market-definition-mobile-detail-open > :not([data-market-definition-mobile-detail-host]) {
display: none !important;
}
.market-detail-grid {
display: grid;
grid-template-columns: minmax(10rem, 0.8fr) minmax(0, 1.2fr);
margin: 0;
}
.market-detail-row {
display: contents;
}
.market-detail-row dt,
.market-detail-row dd {
margin: 0;
padding: var(--spacing-small) 0;
border-bottom: 1px solid var(--color-light-grey);
}
.market-detail-row dt {
color: var(--color-black);
font-weight: var(--font-weight-semibold);
}
.market-detail-row dd {
overflow-wrap: anywhere;
}
.market-edit-form .sg-component-row {
margin-top: var(--spacing-medium);
align-items: center;
}
.market-edit-form .sg-labeled-input-row .sg-input-multi-line {
width: 100%;
max-width: var(--layout-form-input-field-max-width);
}
@media (max-width: 767px) {
.market-detail-grid {
grid-template-columns: minmax(8rem, 0.9fr) minmax(0, 1.1fr);
}
}
.sg-card-list-page-drawer-backdrop {
position: fixed;
inset: 0;
@@ -192,26 +96,6 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
width: 100%;
}
.sg-lager-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
.sg-bestellungen-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
.sg-mobile-detail-back-card,
.sg-mobile-detail-back-card * {
cursor: pointer;
@@ -233,8 +117,9 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-otc-order-overlay__panel {
--layout-scrollable-card-width: min(960px, 100%);
--layout-scrollable-card-height: min(600px, calc(100vh - (2 * var(--spacing-large))));
width: min(960px, 100%);
max-height: calc(100vh - (2 * var(--spacing-large)));
overflow: auto;
}
.sg-otc-order-overlay__title {
@@ -253,19 +138,6 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
color: var(--color-signal-red);
}
.sg-otc-order-overlay [data-otc-order-product] {
flex: 0 0 6rem;
width: 6rem;
max-width: 6rem;
margin-left: auto;
text-align: right;
}
.sg-otc-order-overlay .sg-otc-order-product-row .sg-label {
min-width: 0;
flex: 1 1 auto;
}
.sg-otc-order-overlay .sg-form-sections-card__field-group {
gap: var(--spacing-small);
}
@@ -282,14 +154,6 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
@media (max-width: 767px) {
.sg-otc-order-overlay [data-otc-order-product] {
flex: 1 1 auto;
width: 100%;
max-width: 100%;
margin-left: 0;
text-align: left;
}
.sg-large-table--bestellungen .sg-search-field-row {
width: min(100%, calc(var(--layout-search-field-width) * 0.75));
}
@@ -303,7 +167,7 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-large-table--bestellungen .sg-large-table__row {
grid-template-columns: var(--large-table-grid-template-columns, minmax(0, 1.15fr) minmax(0, 1.35fr) fit-content(12.5ch));
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.35fr) fit-content(12.5ch);
}
.sg-large-table--bestellungen .sg-large-table__row > :nth-child(2) {
@@ -316,7 +180,7 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-large-table--bestellungen .sg-large-table__row {
grid-template-columns: var(--large-table-grid-template-columns, minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.35fr) fit-content(12.5ch));
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.35fr) fit-content(12.5ch);
}
.sg-large-table--bestellungen .sg-large-table__cell--amount {
@@ -339,15 +203,11 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-large-table--lager .sg-large-table__row {
grid-template-columns: var(--large-table-grid-template-columns, repeat(2, minmax(0, 1fr)));
}
.sg-large-table--markets .sg-large-table__row {
grid-template-columns: var(--large-table-grid-template-columns, repeat(6, minmax(0, 1fr)));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row {
grid-template-columns: var(--large-table-grid-template-columns, 30% repeat(4, minmax(0, 1fr)));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child {
@@ -370,142 +230,15 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
color: var(--color-signal-yellow);
}
.sg-large-table--markets .market-status--red {
color: var(--color-signal-red);
}
.sg-large-table--markets .market-status--yellow {
color: var(--color-signal-yellow);
}
.sg-large-table--markets .market-status--green {
color: var(--color-signal-green);
}
.sg-card-list-page-drawer [data-lager-edit-form][hidden],
.sg-card-list-page-drawer [data-lager-read-view][hidden],
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-form"][hidden],
.sg-card-list-page-drawer [data-lager-new-lot-notification-wrapper][hidden],
.sg-card-list-page-drawer [data-lager-open-edit-form][hidden],
.sg-card-list-page-drawer [data-lager-open-card][hidden],
.sg-card-list-page-drawer [data-market-definition-read-card][hidden],
.sg-card-list-page-drawer [data-market-definition-create-card][hidden],
.sg-card-list-page-drawer [data-market-definition-new-organizer][hidden] {
.sg-card-list-page-drawer [data-lager-open-card][hidden] {
display: none;
}
.sg-card-list-page-drawer [data-market-new-municipality][hidden] {
display: none;
}
[data-shared-editable-card-read][hidden],
[data-shared-editable-card-form][hidden] {
display: none;
}
.sg-card-list-page-drawer [data-market-definition-card] .sg-object-card__header > .sg-component-row {
margin-left: auto;
}
.sg-card-list-page-drawer [data-market-delete-occurrence-id],
.sg-card-list-page-drawer [data-market-definition-delete-id] {
display: flex;
flex-direction: column;
gap: var(--spacing-large);
--layout-delete-confirmation-overlay-offset-block-start: 100px;
}
.sg-card-list-page-drawer [data-market-delete-occurrence-id] > .sg-component-row,
.sg-card-list-page-drawer [data-market-definition-delete-id] > .sg-component-row,
.sg-card-list-page-drawer .market-delete-confirmation-card .sg-component-row {
justify-content: flex-end;
}
.market-delete-confirmation-card__title {
margin: 0;
color: var(--color-black);
}
.market-days-group__title {
margin: var(--spacing-small) 0 0;
color: var(--color-white);
}
.market-checklist-list,
.market-checklist-edit-list {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
}
.market-checklist-item,
.market-checklist-edit-item {
display: flex;
align-items: center;
gap: var(--spacing-small);
}
.market-checklist-item .sg-checkbox-field,
.market-checklist-edit-row .sg-checkbox-field {
flex: 0 0 auto;
}
.market-checklist-edit-item [data-checklist-label] {
flex: 1 1 auto;
min-width: 0;
}
.market-checklist-edit-row .sg-label {
display: flex;
align-items: center;
}
.market-checklist-edit-control {
display: flex;
align-items: center;
gap: var(--spacing-small);
flex: 1 1 auto;
width: 100%;
min-width: 0;
max-width: var(--layout-form-input-field-max-width);
}
.market-checklist-edit-control [data-checklist-label] {
flex: 1 1 auto;
width: 100%;
min-width: 0;
max-width: none;
}
.market-checklist-comment-field .sg-input-multi-line {
width: 100%;
max-width: var(--layout-form-input-field-max-width);
}
.market-checklist-delete {
min-width: var(--small-interaction-element-size);
width: var(--small-interaction-element-size);
padding-inline: 0;
}
.market-checklist-comment,
.market-checklist-comment-field {
margin-top: var(--spacing-large);
}
.market-required-field-indicator {
position: relative;
top: -0.25em;
margin-left: 0.15em;
color: var(--color-signal-red);
font-size: 0.75em;
line-height: 0;
}
.sg-card-list-page-drawer [data-component="content-group-box"] {
margin-block: 0;
}
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-action"] {
display: flex;
align-items: center;
@@ -520,22 +253,3 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
[data-bestellungen-mobile-detail] .sg-navigation-card-layout {
margin-top: 0;
}
@media (max-width: 767px) {
.market-checklist-edit-row {
flex-direction: row;
align-items: center;
gap: var(--spacing-small);
}
.market-checklist-edit-row .sg-label {
min-width: 0;
flex: 0 0 auto;
margin-right: 0;
margin-bottom: 0;
}
.market-checklist-edit-control {
max-width: none;
}
}
-67
View File
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../modules/shared/db.php';
require_once __DIR__ . '/../modules/shared/auth/service.php';
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if ($requestMethod !== 'GET') {
http_response_code(405);
header('Allow: GET');
echo 'Methode nicht erlaubt.';
exit;
}
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
auth_require_user($pdo);
$documentRoot = rtrim(env_value('HERMES_DOCUMENT_ROOT', $env), DIRECTORY_SEPARATOR);
if ($documentRoot === '' || !str_starts_with($documentRoot, DIRECTORY_SEPARATOR)) {
http_response_code(500);
echo 'Dokumentablage ist nicht konfiguriert.';
exit;
}
$resolvedRoot = realpath($documentRoot);
$relativePath = (string) ($_GET['path'] ?? '');
if ($resolvedRoot === false || !is_dir($resolvedRoot) || $relativePath === '' || str_contains($relativePath, "\0") || str_contains($relativePath, '\\') || str_starts_with($relativePath, '/')) {
http_response_code(404);
echo 'Dokument nicht gefunden.';
exit;
}
$segments = explode('/', $relativePath);
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
http_response_code(404);
echo 'Dokument nicht gefunden.';
exit;
}
}
$resolvedFile = realpath($resolvedRoot . DIRECTORY_SEPARATOR . $relativePath);
$rootPrefix = $resolvedRoot . DIRECTORY_SEPARATOR;
if ($resolvedFile === false || !is_file($resolvedFile) || !is_readable($resolvedFile) || !str_starts_with($resolvedFile, $rootPrefix)) {
http_response_code(404);
echo 'Dokument nicht gefunden.';
exit;
}
$fileSize = filesize($resolvedFile);
$mimeType = mime_content_type($resolvedFile);
if ($fileSize === false || $mimeType === false) {
http_response_code(500);
echo 'Dokument konnte nicht bereitgestellt werden.';
exit;
}
$downloadName = basename($resolvedFile);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . (string) $fileSize);
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($downloadName));
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
readfile($resolvedFile);
+1 -41
View File
@@ -8,7 +8,6 @@ require_once __DIR__ . '/../modules/erp/bestellungen/service.php';
require_once __DIR__ . '/../modules/erp/lager/service.php';
require_once __DIR__ . '/../modules/erp/kontakte/service.php';
require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php';
require_once __DIR__ . '/../modules/market_management/service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
$pdo = connect_database($env);
@@ -49,7 +48,6 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$currentUser = auth_current_user($pdo);
if ($currentUser !== null) {
$otcProducts = get_otc_order_form_products($pdo);
$otcMarketOptions = market_management_list_active_occurrence_options($pdo);
$lagerProducts = get_lager_product_overview($pdo);
$lagerManufacturers = get_manufacturer_options($pdo);
$lagerArtikel = get_lager_artikel_overview($pdo);
@@ -60,49 +58,11 @@ if ($currentUser !== null) {
'limit' => (int) ($_GET['bestellungen_limit'] ?? 20),
'page_size' => 20,
]);
$marketSearch = (string) ($_GET['market_search'] ?? '');
$marketSort = (string) ($_GET['market_sort'] ?? 'earliest_date');
$marketDirection = strtoupper((string) ($_GET['market_dir'] ?? 'ASC'));
$marketLimit = min(100, max(1, (int) ($_GET['market_limit'] ?? 20)));
$marketOccurrencesTable = [
'rows' => market_management_search_occurrences($pdo, [
'search' => $marketSearch,
'sort' => $marketSort,
'sort_direction' => $marketDirection,
'limit' => $marketLimit,
'offset' => 0,
]),
'search' => $marketSearch,
'sort_column' => $marketSort,
'sort_direction' => $marketDirection,
'limit' => $marketLimit,
'page_size' => 20,
];
$marketOccurrencesTable['has_more'] = count($marketOccurrencesTable['rows']) >= $marketLimit;
$marketDefinitionSort = (string) ($_GET['market_definition_sort'] ?? 'name');
$marketDefinitionDirection = strtoupper((string) ($_GET['market_definition_dir'] ?? 'ASC'));
$marketDefinitionsTable = [
'rows' => market_management_search_markets($pdo, [
'include_archived' => false,
'sort' => $marketDefinitionSort,
'sort_direction' => $marketDefinitionDirection,
'limit' => 100,
'offset' => 0,
]),
'categories' => market_management_list_categories($pdo),
'organizers' => market_management_list_organizers($pdo),
'sort_column' => $marketDefinitionSort,
'sort_direction' => $marketDefinitionDirection,
];
if ((string) ($_GET['bestellungen_fragment'] ?? '') === '1') {
echo auth_render_bestellungen_content($bestellungenTable);
exit;
}
if ((string) ($_GET['market_fragment'] ?? '') === '1') {
echo auth_render_markets_content($marketOccurrencesTable);
exit;
}
render_auth_home_page($currentUser, $otcProducts, $otcMarketOptions, $bestellungenTable, $marketOccurrencesTable, $lagerProducts, $lagerArtikel, $lagerManufacturers, $marketDefinitionsTable);
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers);
exit;
}
-245
View File
@@ -1,245 +0,0 @@
(function initLargeTableColumnResizeModule() {
const STORAGE_PREFIX = 'sg:large-table:column-widths:';
const STORAGE_VERSION = 3;
const DEFAULT_MIN_WIDTH = 128;
const KEYBOARD_STEP = 8;
const KEYBOARD_LARGE_STEP = 40;
let activeResize = null;
const getTables = (root) => root.querySelectorAll('[data-component="large-table"][data-column-resize="true"]');
const getColumnHeaders = (table) => Array.from(
table.querySelectorAll('[data-component-part="large-table-header-row"] [role="columnheader"]'),
);
const getMinWidth = (table) => {
const configured = table.dataset.columnMinWidth;
const value = configured || getComputedStyle(table).getPropertyValue('--large-table-column-min-width').trim();
if (!value) {
return DEFAULT_MIN_WIDTH;
}
const probe = document.createElement('span');
probe.style.cssText = `position:absolute;width:${value};visibility:hidden;`;
table.appendChild(probe);
const width = probe.getBoundingClientRect().width;
probe.remove();
return width > 0 ? width : DEFAULT_MIN_WIDTH;
};
const parseDefaultWidth = (rawValue, tableWidth) => {
if (!rawValue) {
return null;
}
const value = Number.parseFloat(rawValue);
if (!Number.isFinite(value) || value <= 0) {
return null;
}
if (rawValue.trim().endsWith('%')) {
return tableWidth * value / 100;
}
if (rawValue.trim().endsWith('fr')) {
return value;
}
return value;
};
const normalizeRatios = (values) => {
const safeValues = values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0));
const total = safeValues.reduce((sum, value) => sum + value, 0);
if (total <= 0) {
return Array(values.length).fill(1 / values.length);
}
return safeValues.map((value) => value / total);
};
const getDefaultRatios = (table, headers, minWidth) => {
const tableWidth = Math.max(table.querySelector('[data-component-part="large-table-viewport"]')?.clientWidth || 0, minWidth * headers.length);
const configured = headers.map((header) => parseDefaultWidth(header.dataset.defaultWidth, tableWidth));
if (configured.some((width) => width === null)) {
return Array(headers.length).fill(1 / headers.length);
}
return normalizeRatios(configured);
};
const getStorageKey = (table) => `${STORAGE_PREFIX}${table.dataset.columnResizeKey || ''}`;
const readStoredRatios = (table, count) => {
const key = getStorageKey(table);
if (!table.dataset.columnResizeKey) {
return null;
}
try {
if (!window.localStorage) {
return null;
}
const stored = JSON.parse(window.localStorage.getItem(key));
if (stored?.version !== STORAGE_VERSION || !Array.isArray(stored.ratios) || stored.ratios.length !== count) {
return null;
}
if (stored.ratios.some((ratio) => !Number.isFinite(ratio) || ratio <= 0)) {
return null;
}
return normalizeRatios(stored.ratios);
} catch (error) {
return null;
}
};
const writeStoredRatios = (table, ratios) => {
if (!table.dataset.columnResizeKey) {
return;
}
try {
if (!window.localStorage) {
return;
}
window.localStorage.setItem(getStorageKey(table), JSON.stringify({
version: STORAGE_VERSION,
ratios: normalizeRatios(ratios),
}));
} catch (error) {
// Storage can be unavailable in privacy-restricted browser contexts.
}
};
const updateHandleState = (table, widths, minWidth) => {
const handles = table.querySelectorAll('[data-large-table-resize-handle]');
handles.forEach((handle) => {
const index = Number.parseInt(handle.dataset.largeTableResizeHandle, 10);
const current = widths[index];
const maximum = Math.max(minWidth, widths[index] + widths[index + 1] - minWidth);
if (table.id) {
handle.setAttribute('aria-controls', table.id);
}
handle.setAttribute('aria-valuemin', String(Math.round(minWidth)));
handle.setAttribute('aria-valuemax', String(Math.round(maximum)));
handle.setAttribute('aria-valuenow', String(Math.round(current)));
handle.setAttribute('aria-valuetext', `${Math.round(current)} Pixel`);
});
};
const getRenderedWidths = (table, count, minWidth) => {
const headers = getColumnHeaders(table);
const widths = headers.map((header) => header.getBoundingClientRect().width);
if (widths.length === count && widths.every((width) => width > 0)) {
return widths;
}
return Array(count).fill(minWidth);
};
const applyRatios = (table, ratios, minWidth) => {
const safeRatios = normalizeRatios(ratios);
const columnTemplate = safeRatios
.map((ratio) => `minmax(${Math.ceil(minWidth)}px, ${ratio}fr)`)
.join(' ');
table.style.setProperty('--large-table-grid-template-columns', columnTemplate);
table.style.setProperty('--large-table-grid-min-width', `${Math.ceil(minWidth * safeRatios.length)}px`);
table._largeTableColumnRatios = safeRatios;
table._largeTableColumnWidths = getRenderedWidths(table, safeRatios.length, minWidth);
updateHandleState(table, table._largeTableColumnWidths, minWidth);
};
const resizeColumn = (table, index, delta, persist, baseWidths = table._largeTableColumnWidths) => {
const widths = [...baseWidths];
const minWidth = getMinWidth(table);
const nextLeft = Math.max(minWidth, Math.min(widths[index] + widths[index + 1] - minWidth, widths[index] + delta));
const appliedDelta = nextLeft - widths[index];
widths[index] = nextLeft;
widths[index + 1] -= appliedDelta;
applyRatios(table, widths.map((width) => width / widths.reduce((sum, value) => sum + value, 0)), minWidth);
if (persist) {
writeStoredRatios(table, table._largeTableColumnRatios);
}
};
const initTable = (table) => {
const headers = getColumnHeaders(table);
if (headers.length < 2) {
return;
}
const minWidth = getMinWidth(table);
const stored = readStoredRatios(table, headers.length);
applyRatios(table, stored || getDefaultRatios(table, headers, minWidth), minWidth);
if (table.dataset.largeTableResizeInitialized === 'true') {
return;
}
table.dataset.largeTableResizeInitialized = 'true';
table.addEventListener('pointerdown', (event) => {
const handle = event.target.closest('[data-large-table-resize-handle]');
if (!handle || !table.contains(handle)) {
return;
}
event.preventDefault();
activeResize = {
table,
handle,
index: Number.parseInt(handle.dataset.largeTableResizeHandle, 10),
startX: event.clientX,
startWidths: [...table._largeTableColumnWidths],
};
handle.dataset.largeTableResizeActive = 'true';
table.dataset.columnResizeState = 'resizing';
handle.setPointerCapture?.(event.pointerId);
});
table.addEventListener('pointermove', (event) => {
if (!activeResize || activeResize.table !== table) {
return;
}
resizeColumn(
table,
activeResize.index,
event.clientX - activeResize.startX,
false,
activeResize.startWidths,
);
});
table.addEventListener('pointerup', () => {
if (!activeResize || activeResize.table !== table) {
return;
}
writeStoredRatios(table, table._largeTableColumnRatios);
activeResize.handle.dataset.largeTableResizeActive = 'false';
table.dataset.columnResizeState = 'idle';
activeResize = null;
});
if (typeof ResizeObserver === 'function') {
table._largeTableResizeObserver = new ResizeObserver(() => {
applyRatios(table, table._largeTableColumnRatios, getMinWidth(table));
});
table._largeTableResizeObserver.observe(table.querySelector('[data-component-part="large-table-viewport"]'));
}
table.addEventListener('keydown', (event) => {
const handle = event.target.closest('[data-large-table-resize-handle]');
if (!handle || !table.contains(handle)) {
return;
}
const index = Number.parseInt(handle.dataset.largeTableResizeHandle, 10);
const step = event.shiftKey ? KEYBOARD_LARGE_STEP : KEYBOARD_STEP;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
resizeColumn(table, index, event.key === 'ArrowRight' ? step : -step, true);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
const minWidth = getMinWidth(table);
const current = table._largeTableColumnWidths[index];
const target = event.key === 'Home' ? minWidth : current + table._largeTableColumnWidths[index + 1] - minWidth;
resizeColumn(table, index, target - current, true);
}
});
};
window.sgInitLargeTableColumnResize = (options = {}) => {
const root = options.root || document;
getTables(root).forEach(initTable);
};
document.addEventListener('DOMContentLoaded', () => window.sgInitLargeTableColumnResize(), { once: true });
})();
@@ -1,241 +0,0 @@
(function () {
'use strict';
const cardSelector = '[data-shared-editable-action-card]';
const scrollRestoreStates = new WeakMap();
const getCard = (element) => element.closest(cardSelector);
const setToggleEnabled = (card, enabled) => {
const toggle = card?.querySelector('[data-shared-editable-card-toggle]');
if (!toggle) {
return;
}
toggle.disabled = !enabled;
toggle.setAttribute('aria-disabled', String(!enabled));
toggle.classList.toggle('sg-button--inactive', !enabled);
};
const setVisibility = (card, visible) => {
if (!card || !card.hasAttribute('data-shared-editable-card-collapsible')) {
return;
}
const readView = card.querySelector('[data-shared-editable-card-read]');
const toggle = card.querySelector('[data-shared-editable-card-toggle]');
card.dataset.sharedEditableCardVisible = String(visible);
card.classList.toggle('shared-editable-action-card--collapsed', !visible);
if (readView && card.querySelector('[data-shared-editable-card-form]')?.hidden !== false) {
readView.hidden = !visible;
}
if (toggle) {
toggle.textContent = visible ? 'Verbergen' : 'Anzeigen';
toggle.setAttribute('aria-expanded', String(visible));
}
};
const isVisible = (card) => card?.dataset.sharedEditableCardVisible === 'true';
const getScrollContainer = (element) => {
let candidate = element?.parentElement || null;
while (candidate && candidate !== document.body) {
const style = window.getComputedStyle(candidate);
const scrollable = /(auto|scroll|overlay)/.test(style.overflowY);
if (scrollable && candidate.scrollHeight > candidate.clientHeight) {
return candidate;
}
candidate = candidate.parentElement;
}
return document.scrollingElement || document.documentElement;
};
const captureScrollPosition = (root, card) => {
const container = getScrollContainer(card);
scrollRestoreStates.set(root, {
card,
container,
top: container.scrollTop,
left: container.scrollLeft,
timer: null,
});
};
const restoreScrollPosition = (root) => {
const state = scrollRestoreStates.get(root);
if (!state) {
return;
}
if (state.timer) {
window.clearTimeout(state.timer);
}
state.timer = window.setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (state.container?.isConnected) {
state.container.scrollTo({ top: state.top, left: state.left, behavior: 'auto' });
}
scrollRestoreStates.delete(root);
});
});
}, 100);
};
const getDirectDeleteElement = (card, selector) => Array.from(card?.children || [])
.find((child) => child.matches(selector)) || null;
const setEditMode = (card, enabled) => {
if (!card) {
return;
}
const readView = card.querySelector('[data-shared-editable-card-read]');
const editView = card.querySelector('[data-shared-editable-card-form]');
const editButtons = card.querySelectorAll('[data-shared-editable-card-edit]');
if (card.hasAttribute('data-shared-editable-card-collapsible')) {
if (enabled) {
setVisibility(card, true);
} else if (!card.dataset.sharedEditableCardVisible) {
setVisibility(card, !card.hasAttribute('data-shared-editable-card-collapsed'));
}
setToggleEnabled(card, !enabled);
}
if (readView) {
readView.hidden = enabled;
}
if (editView) {
editView.hidden = !enabled;
}
editButtons.forEach((editButton) => {
editButton.disabled = enabled;
editButton.setAttribute('aria-disabled', String(enabled));
editButton.classList.toggle('sg-button--inactive', enabled);
});
if (card.hasAttribute('data-shared-editable-card-collapsible') && enabled && readView) {
readView.hidden = true;
}
};
const resetDeleteConfirmation = (card) => {
const stage = card?.closest('.sg-delete-confirmation-pattern__stage');
const confirmation = getDirectDeleteElement(card, '[data-shared-editable-card-delete-confirmation]');
const input = confirmation?.querySelector('[data-shared-editable-card-delete-input]');
const submit = confirmation?.querySelector('[data-shared-editable-card-delete-submit]');
if (input) {
input.value = '';
}
if (submit) {
submit.disabled = true;
submit.setAttribute('aria-disabled', 'true');
submit.classList.add('sg-button--process-inactive');
}
if (confirmation) {
confirmation.hidden = true;
}
if (stage) {
stage.dataset.dialogOpen = 'false';
}
};
const openDeleteConfirmation = (card) => {
const stage = card?.closest('.sg-delete-confirmation-pattern__stage');
const confirmation = getDirectDeleteElement(card, '[data-shared-editable-card-delete-confirmation]');
const input = confirmation?.querySelector('[data-shared-editable-card-delete-input]');
if (input) {
input.value = '';
input.focus();
}
if (confirmation) {
confirmation.hidden = false;
}
if (stage) {
stage.dataset.dialogOpen = 'true';
}
};
const updateDeleteButton = (input) => {
const card = getCard(input);
const confirmation = input?.closest('[data-shared-editable-card-delete-confirmation]');
const submit = confirmation?.querySelector('[data-shared-editable-card-delete-submit]');
const token = card?.dataset.sharedEditableDeleteToken || 'DELETE';
const valid = input.value === token;
if (submit) {
submit.disabled = !valid;
submit.setAttribute('aria-disabled', String(!valid));
submit.classList.toggle('sg-button--process-inactive', !valid);
}
};
const init = (root) => {
if (!root || root.dataset.sharedEditableActionCardsInitialized === 'true') {
return;
}
root.dataset.sharedEditableActionCardsInitialized = 'true';
root.querySelectorAll(cardSelector).forEach((card) => setEditMode(card, false));
root.addEventListener('click', (event) => {
const toggle = event.target.closest('[data-shared-editable-card-toggle]');
if (toggle && root.contains(toggle)) {
const card = getCard(toggle);
if (card?.querySelector('[data-shared-editable-card-form]')?.hidden === false) {
return;
}
setVisibility(card, !isVisible(card));
return;
}
const edit = event.target.closest('[data-shared-editable-card-edit]');
if (edit && root.contains(edit)) {
const card = getCard(edit);
captureScrollPosition(root, card);
setEditMode(card, true);
return;
}
const cancel = event.target.closest('[data-shared-editable-card-cancel]');
if (cancel && root.contains(cancel)) {
const card = getCard(cancel);
resetDeleteConfirmation(card);
setEditMode(card, false);
scrollRestoreStates.delete(root);
return;
}
const deleteButton = event.target.closest('[data-shared-editable-card-delete]');
if (deleteButton && root.contains(deleteButton)) {
openDeleteConfirmation(getCard(deleteButton));
return;
}
const deleteCancel = event.target.closest('[data-shared-editable-card-delete-cancel]');
if (deleteCancel && root.contains(deleteCancel)) {
resetDeleteConfirmation(getCard(deleteCancel));
}
});
const scrollObserver = new MutationObserver(() => {
const state = scrollRestoreStates.get(root);
if (state && !root.contains(state.card)) {
restoreScrollPosition(root);
}
});
scrollObserver.observe(root, { childList: true, subtree: true });
root.addEventListener('input', (event) => {
const input = event.target.closest('[data-shared-editable-card-delete-input]');
if (input && root.contains(input)) {
updateDeleteButton(input);
}
});
};
window.SharedEditableActionCard = { init, resetDeleteConfirmation, setEditMode, setToggleEnabled, setVisibility, isVisible };
}());
-85
View File
@@ -1,85 +0,0 @@
(function () {
'use strict';
const create = ({ contentRoot, bodyClass, hostClass, hostDataAttribute, drawerDataAttribute, backdropSelector, backMarkup, historyKey }) => {
let host = null;
let drawer = null;
let trigger = null;
let open = false;
const restore = () => {
if (host && host.parentNode) {
const mobileDrawer = host.querySelector('[' + drawerDataAttribute + ']');
if (mobileDrawer) {
contentRoot.appendChild(mobileDrawer);
mobileDrawer.removeAttribute(drawerDataAttribute);
mobileDrawer.dataset.open = 'false';
mobileDrawer.setAttribute('aria-hidden', 'true');
}
host.parentNode.removeChild(host);
}
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
}
const backdrops = backdropSelector
? [contentRoot.querySelector(backdropSelector)].filter(Boolean)
: Array.from(contentRoot.querySelectorAll('.sg-card-list-page-drawer-backdrop'));
backdrops.forEach((backdrop) => {
backdrop.dataset.open = 'false';
backdrop.setAttribute('aria-hidden', 'true');
});
host = null;
drawer = null;
trigger = null;
open = false;
document.body.classList.remove(bodyClass);
document.body.classList.remove('sg-card-list-page-drawer-open');
};
const mount = (sourceDrawer, detailTrigger, existingHost = null) => {
restore();
if (!sourceDrawer) {
return null;
}
host = existingHost || document.createElement('div');
host.className = hostClass;
host.setAttribute(hostDataAttribute, 'true');
if (!existingHost) {
host.innerHTML = backMarkup;
}
drawer = sourceDrawer;
trigger = detailTrigger;
drawer.setAttribute(drawerDataAttribute, 'true');
drawer.dataset.open = 'true';
drawer.setAttribute('aria-hidden', 'false');
host.appendChild(drawer);
document.body.appendChild(host);
document.body.classList.add(bodyClass);
open = true;
if (historyKey) {
window.history.pushState({ ...(window.history.state || {}), [historyKey]: 'detail' }, '', window.location.href);
}
return { host, drawer, trigger: detailTrigger };
};
const close = () => {
if (!open) {
return;
}
if (window.history.state && window.history.state[historyKey] === 'detail') {
window.history.back();
return;
}
restore();
};
return { mount, restore, close, isOpen: () => open, getHost: () => host, getDrawer: () => drawer };
};
window.SharedMobileDetail = { create };
}());
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/erp/lager/mcp.php';
function assert_test(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
}
$tools = erp_lager_mcp_tools();
assert_test(count($tools) === 2, 'Expected 2 ERP lager MCP tools, got ' . count($tools));
$names = array_map(static fn (array $tool): string => $tool['name'], $tools);
assert_test($names === ['erp_lager.list_products', 'erp_lager.get_product'], 'Unexpected ERP lager MCP tool names');
$productTool = $tools[1];
assert_test($productTool['inputSchema']['required'] === ['product_id'], 'Product detail tool must require product_id');
assert_test($productTool['inputSchema']['properties']['product_id']['type'] === 'integer', 'Product ID must be an integer');
assert_test(is_object($tools[0]['inputSchema']['properties']), 'List products properties must serialize as a JSON object');
fwrite(STDOUT, "erp_lager_mcp_tools_list_test: OK\n");
@@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/market_management/mcp.php';
function fail_test(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
function assert_test(bool $condition, string $message): void
{
if (!$condition) {
fail_test($message);
}
}
function assert_schema_objects(array $schema, string $path = 'inputSchema'): void
{
assert_test(($schema['type'] ?? null) === 'object', $path . '.type must be object');
assert_test(isset($schema['properties']) && (is_array($schema['properties']) || is_object($schema['properties'])), $path . '.properties must be an object-compatible map');
if (isset($schema['required'])) {
assert_test(is_array($schema['required']), $path . '.required must be an array');
foreach ($schema['required'] as $required) {
assert_test(is_string($required), $path . '.required entries must be strings');
}
}
$properties = is_object($schema['properties'])
? get_object_vars($schema['properties'])
: $schema['properties'];
foreach ($properties as $propertyName => $propertySchema) {
assert_test(is_array($propertySchema), $path . '.properties.' . $propertyName . ' must be an object schema');
if (($propertySchema['type'] ?? null) === 'array' && isset($propertySchema['items'])) {
assert_test(is_array($propertySchema['items']), $path . '.properties.' . $propertyName . '.items must be an object schema');
if (($propertySchema['items']['type'] ?? null) === 'object') {
assert_schema_objects($propertySchema['items'], $path . '.properties.' . $propertyName . '.items');
}
}
}
}
$expected = [
'market_management.search_markets' => 'Märkte suchen',
'market_management.search_occurrences' => 'Marktdurchführungen suchen und auflisten',
'market_management.get_market' => 'Markt mit Marktdurchführungen lesen',
'market_management.create_market' => 'Markt mit verpflichtendem Veranstalter, Kategorie und optionaler Website-URL anlegen',
'market_management.update_market' => 'Markt inklusive Veranstalter und Website-URL ändern',
'market_management.archive_market' => 'Markt archivieren',
'market_management.reactivate_market' => 'Markt reaktivieren',
'market_management.list_categories' => 'Bestehende Kategorien lesen',
'market_management.list_market_organizers' => 'Aktive Veranstalter lesen',
'market_management.create_market_organizer' => 'Veranstalter mit optionalen Kontaktdaten anlegen',
'market_management.list_reference_data' => 'Kategorien, Kantone und Gemeinden lesen',
'market_management.create_municipality' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen',
'market_management.get_overview' => 'Marktübersicht mit serverseitigen Filtern lesen',
'market_management.create_occurrence' => 'Marktdurchführung mit mindestens einem Markttag anlegen',
'market_management.get_occurrence' => 'Marktdurchführung mit Markt, Markttagen und berechneten Verkaufsdaten lesen',
'market_management.get_occurrence_checklist' => 'Checkliste einer Marktdurchführung lesen',
'market_management.update_occurrence_checklist' => 'Checkliste einer Marktdurchführung vollständig ersetzen',
'market_management.update_occurrence' => 'Marktdurchführung ändern',
'market_management.archive_occurrence' => 'Marktdurchführung archivieren',
'market_management.reactivate_occurrence' => 'Marktdurchführung reaktivieren',
'market_management.create_day' => 'Markttag anlegen',
'market_management.update_day' => 'Markttag ändern',
'market_management.delete_day' => 'Markttag entfernen',
];
$tools = market_management_mcp_tools();
assert_test(count($tools) === 23, 'Expected 23 MCP tools, got ' . count($tools));
$occurrenceTool = array_values(array_filter($tools, static fn (array $tool): bool => $tool['name'] === 'market_management.update_occurrence'))[0] ?? null;
assert_test(is_array($occurrenceTool), 'Update occurrence MCP tool must exist');
assert_test(isset($occurrenceTool['inputSchema']['properties']['registration_note']), 'MCP occurrence schema must expose registration_note');
$createOccurrenceTool = array_values(array_filter($tools, static fn (array $tool): bool => $tool['name'] === 'market_management.create_occurrence'))[0] ?? null;
assert_test(is_array($createOccurrenceTool), 'Create occurrence MCP tool must exist');
$createProperties = $createOccurrenceTool['inputSchema']['properties'];
assert_test(isset($createProperties['place'], $createProperties['days']['items']['properties']['date']), 'Create occurrence schema must expose both levels');
assert_test(!isset($createProperties['days']['items']['properties']['place']), 'Occurrence fields must not be nested under days');
$writeTools = [
'market_management.create_market', 'market_management.update_market',
'market_management.create_market_organizer', 'market_management.create_municipality',
'market_management.archive_market', 'market_management.reactivate_market',
'market_management.create_occurrence', 'market_management.update_occurrence',
'market_management.update_occurrence_checklist', 'market_management.archive_occurrence',
'market_management.reactivate_occurrence', 'market_management.create_day',
'market_management.update_day', 'market_management.delete_day',
];
foreach ($writeTools as $writeToolName) {
$writeTool = array_values(array_filter($tools, static fn (array $tool): bool => $tool['name'] === $writeToolName))[0] ?? null;
assert_test(is_array($writeTool), $writeToolName . ' must exist');
assert_test(in_array('idempotency_key', $writeTool['inputSchema']['required'] ?? [], true), $writeToolName . ' must require idempotency_key');
assert_test(isset($writeTool['inputSchema']['properties']['idempotency_key']), $writeToolName . ' must expose idempotency_key');
}
$actual = [];
foreach ($tools as $tool) {
assert_test(isset($tool['name'], $tool['description'], $tool['inputSchema']), 'Tool must contain name, description and inputSchema');
assert_schema_objects($tool['inputSchema'], $tool['name'] . '.inputSchema');
$actual[$tool['name']] = $tool['description'];
}
assert_test($actual === $expected, 'MCP tool names or descriptions changed unexpectedly');
fwrite(STDOUT, "market_management_mcp_tools_list_test: OK\n");
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/market_management/service.php';
function fail_test(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
function assert_test(bool $condition, string $message): void
{
if (!$condition) {
fail_test($message);
}
}
$plainText = "Test 'A' & <B> \"C\"";
$sanitized = market_management_sanitize_multiline_html($plainText);
assert_test($sanitized === "Test 'A' &amp; &lt;B&gt; \"C\"", 'Plain text must preserve quotes and escape markup characters once');
$link = '<a href="https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2Fvertrag.pdf">Vertrag</a>';
$sanitizedLink = market_management_sanitize_multiline_html($link);
assert_test(str_contains($sanitizedLink, 'class="sg-hyperlink"'), 'Allowed links must remain links');
assert_test(str_contains($sanitizedLink, 'href="https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2Fvertrag.pdf"'), 'Link href must remain valid');
fwrite(STDOUT, "market_management_sanitizer_test: OK\n");
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/market_management/service.php';
function fail_test(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
function assert_test(bool $condition, string $message): void
{
if (!$condition) {
fail_test($message);
}
}
$normalized = market_management_optional_url(['website_url' => 'www.stadtaffoltern.ch'], 'website_url');
assert_test($normalized === 'https://www.stadtaffoltern.ch', 'Website without scheme must be normalized to HTTPS');
$existingScheme = market_management_optional_url(['website_url' => 'http://www.stadtaffoltern.ch'], 'website_url');
assert_test($existingScheme === 'http://www.stadtaffoltern.ch', 'Existing HTTP scheme must be preserved');
try {
market_management_optional_url(['website_url' => 'not a url'], 'website_url');
fail_test('Invalid website URL must be rejected');
} catch (InvalidArgumentException) {
}
fwrite(STDOUT, "market_management_url_test: OK\n");