Compare commits

..

No commits in common. "b316d0baf8df8f0a555eebb1d9bcbc88369b038d" and "79e996ab034972fca96827d5490843c46b86cfde" have entirely different histories.

53 changed files with 494 additions and 3354 deletions

View File

@ -67,18 +67,3 @@ S3_PATH_STYLE=true
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> Wallet-Pässe (Apple/Google) ###
# Apple Wallet (PassKit) leer = deaktiviert. PEM-Dateien außerhalb des Webroots ablegen.
APPLE_WALLET_PASS_TYPE_ID=
APPLE_WALLET_TEAM_ID=
APPLE_WALLET_ORG_NAME=vcard4reseller
APPLE_WALLET_CERT_PATH=
APPLE_WALLET_KEY_PATH=
APPLE_WALLET_KEY_PASSWORD=
APPLE_WALLET_WWDR_PATH=
# Google Wallet leer = deaktiviert.
GOOGLE_WALLET_ISSUER_ID=
GOOGLE_WALLET_SERVICE_ACCOUNT=
GOOGLE_WALLET_CLASS_SUFFIX=vcard_generic
###< Wallet-Pässe ###

View File

@ -5,8 +5,8 @@ security:
providers:
app_user_provider:
entity:
class: App\Entity\Employee
property: loginEmail
class: App\Entity\User
property: email
firewalls:
dev:
@ -41,10 +41,9 @@ security:
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy:
ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE, ROLE_CONTACT]
ROLE_RESELLER_ADMIN: [ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE, ROLE_CONTACT]
ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE, ROLE_CONTACT]
ROLE_EMPLOYEE: [ROLE_CONTACT]
ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE]
ROLE_RESELLER_ADMIN: [ROLE_COMPANY_ADMIN]
ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE]
when@test:
security:

View File

@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260601135652 extends AbstractMigration
final class Version20260530191712 extends AbstractMigration
{
public function getDescription(): string
{
@ -20,15 +20,14 @@ final class Version20260601135652 extends AbstractMigration
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE card_template (id BINARY(16) NOT NULL, name VARCHAR(120) NOT NULL, type VARCHAR(20) NOT NULL, width_mm DOUBLE PRECISION NOT NULL, height_mm DOUBLE PRECISION NOT NULL, bleed_mm DOUBLE PRECISION NOT NULL, safe_mm DOUBLE PRECISION NOT NULL, front JSON NOT NULL, back JSON NOT NULL, background_path VARCHAR(255) DEFAULT NULL, fonts JSON NOT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) DEFAULT NULL, INDEX IDX_2E51D100979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE company (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, slug VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL, is_reseller_org TINYINT NOT NULL, self_edit_enabled TINYINT NOT NULL, branding_config JSON NOT NULL, created_at DATETIME NOT NULL, reseller_id BINARY(16) NOT NULL, INDEX IDX_4FBF094F91E6A19D (reseller_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE company (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, slug VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL, self_edit_enabled TINYINT NOT NULL, branding_config JSON NOT NULL, created_at DATETIME NOT NULL, reseller_id BINARY(16) NOT NULL, INDEX IDX_4FBF094F91E6A19D (reseller_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE contact_link (id BINARY(16) NOT NULL, type VARCHAR(40) NOT NULL, url VARCHAR(500) NOT NULL, label VARCHAR(120) DEFAULT NULL, position INT NOT NULL, employee_id BINARY(16) NOT NULL, INDEX IDX_1E531B0E8C03F15C (employee_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE domain (id BINARY(16) NOT NULL, hostname VARCHAR(255) NOT NULL, type VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, tls_status VARCHAR(20) NOT NULL, verification_checked_at DATETIME DEFAULT NULL, company_id BINARY(16) NOT NULL, UNIQUE INDEX UNIQ_A7A91E0BE551C011 (hostname), INDEX IDX_A7A91E0B979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE employee (id BINARY(16) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, slug VARCHAR(120) NOT NULL, short_code VARCHAR(16) DEFAULT NULL, title VARCHAR(150) DEFAULT NULL, position VARCHAR(150) DEFAULT NULL, department VARCHAR(150) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, mobile VARCHAR(50) DEFAULT NULL, photo_path VARCHAR(255) DEFAULT NULL, bio LONGTEXT DEFAULT NULL, status VARCHAR(20) NOT NULL, self_edit_allowed TINYINT NOT NULL, editable_fields JSON NOT NULL, login_email VARCHAR(180) DEFAULT NULL, password VARCHAR(255) DEFAULT NULL, roles JSON NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, company_id BINARY(16) NOT NULL, location_id BINARY(16) DEFAULT NULL, INDEX IDX_5D9F75A1979B1AD6 (company_id), INDEX IDX_5D9F75A164D218E (location_id), UNIQUE INDEX uniq_employee_company_slug (company_id, slug), UNIQUE INDEX uniq_employee_shortcode (short_code), UNIQUE INDEX uniq_employee_login_email (login_email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE employee (id BINARY(16) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, slug VARCHAR(120) NOT NULL, title VARCHAR(150) DEFAULT NULL, position VARCHAR(150) DEFAULT NULL, department VARCHAR(150) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, mobile VARCHAR(50) DEFAULT NULL, photo_path VARCHAR(255) DEFAULT NULL, bio LONGTEXT DEFAULT NULL, status VARCHAR(20) NOT NULL, self_edit_allowed TINYINT NOT NULL, editable_fields JSON NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, company_id BINARY(16) NOT NULL, location_id BINARY(16) DEFAULT NULL, INDEX IDX_5D9F75A1979B1AD6 (company_id), INDEX IDX_5D9F75A164D218E (location_id), UNIQUE INDEX uniq_employee_company_slug (company_id, slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE location (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, street VARCHAR(255) DEFAULT NULL, postal_code VARCHAR(20) DEFAULT NULL, city VARCHAR(120) DEFAULT NULL, country VARCHAR(2) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, branding_override JSON NOT NULL, company_id BINARY(16) NOT NULL, INDEX IDX_5E9E89CB979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE platform_plan (id BINARY(16) NOT NULL, name VARCHAR(100) NOT NULL, slug VARCHAR(100) NOT NULL, price_per_month INT NOT NULL, max_profiles INT NOT NULL, max_companies INT NOT NULL, features JSON NOT NULL, UNIQUE INDEX UNIQ_59523C51989D9B62 (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE reseller (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, slug VARCHAR(100) NOT NULL, primary_domain VARCHAR(255) DEFAULT NULL, status VARCHAR(20) NOT NULL, is_platform TINYINT NOT NULL, branding_config JSON NOT NULL, created_at DATETIME NOT NULL, platform_plan_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_18015899989D9B62 (slug), INDEX IDX_18015899FDA9C8C9 (platform_plan_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE card_template ADD CONSTRAINT FK_2E51D100979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('CREATE TABLE reseller (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, slug VARCHAR(100) NOT NULL, primary_domain VARCHAR(255) DEFAULT NULL, status VARCHAR(20) NOT NULL, branding_config JSON NOT NULL, created_at DATETIME NOT NULL, platform_plan_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_18015899989D9B62 (slug), INDEX IDX_18015899FDA9C8C9 (platform_plan_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE `user` (id BINARY(16) NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, last_login_at DATETIME DEFAULT NULL, reseller_id BINARY(16) DEFAULT NULL, company_id BINARY(16) DEFAULT NULL, employee_id BINARY(16) DEFAULT NULL, INDEX IDX_8D93D64991E6A19D (reseller_id), INDEX IDX_8D93D649979B1AD6 (company_id), UNIQUE INDEX UNIQ_8D93D6498C03F15C (employee_id), UNIQUE INDEX uniq_user_email (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE company ADD CONSTRAINT FK_4FBF094F91E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)');
$this->addSql('ALTER TABLE contact_link ADD CONSTRAINT FK_1E531B0E8C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)');
$this->addSql('ALTER TABLE domain ADD CONSTRAINT FK_A7A91E0B979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
@ -36,12 +35,14 @@ final class Version20260601135652 extends AbstractMigration
$this->addSql('ALTER TABLE employee ADD CONSTRAINT FK_5D9F75A164D218E FOREIGN KEY (location_id) REFERENCES location (id)');
$this->addSql('ALTER TABLE location ADD CONSTRAINT FK_5E9E89CB979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE reseller ADD CONSTRAINT FK_18015899FDA9C8C9 FOREIGN KEY (platform_plan_id) REFERENCES platform_plan (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D64991E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D649979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D6498C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE card_template DROP FOREIGN KEY FK_2E51D100979B1AD6');
$this->addSql('ALTER TABLE company DROP FOREIGN KEY FK_4FBF094F91E6A19D');
$this->addSql('ALTER TABLE contact_link DROP FOREIGN KEY FK_1E531B0E8C03F15C');
$this->addSql('ALTER TABLE domain DROP FOREIGN KEY FK_A7A91E0B979B1AD6');
@ -49,7 +50,9 @@ final class Version20260601135652 extends AbstractMigration
$this->addSql('ALTER TABLE employee DROP FOREIGN KEY FK_5D9F75A164D218E');
$this->addSql('ALTER TABLE location DROP FOREIGN KEY FK_5E9E89CB979B1AD6');
$this->addSql('ALTER TABLE reseller DROP FOREIGN KEY FK_18015899FDA9C8C9');
$this->addSql('DROP TABLE card_template');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D64991E6A19D');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D649979B1AD6');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D6498C03F15C');
$this->addSql('DROP TABLE company');
$this->addSql('DROP TABLE contact_link');
$this->addSql('DROP TABLE domain');
@ -57,5 +60,6 @@ final class Version20260601135652 extends AbstractMigration
$this->addSql('DROP TABLE location');
$this->addSql('DROP TABLE platform_plan');
$this->addSql('DROP TABLE reseller');
$this->addSql('DROP TABLE `user`');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260531085615 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE employee ADD short_code VARCHAR(16) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_shortcode ON employee (short_code)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX uniq_employee_shortcode ON employee');
$this->addSql('ALTER TABLE employee DROP short_code');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260531092327 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE card_template (id BINARY(16) NOT NULL, name VARCHAR(120) NOT NULL, type VARCHAR(20) NOT NULL, width_mm DOUBLE PRECISION NOT NULL, height_mm DOUBLE PRECISION NOT NULL, bleed_mm DOUBLE PRECISION NOT NULL, safe_mm DOUBLE PRECISION NOT NULL, front JSON NOT NULL, back JSON NOT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) DEFAULT NULL, INDEX IDX_2E51D100979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE card_template ADD CONSTRAINT FK_2E51D100979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE card_template DROP FOREIGN KEY FK_2E51D100979B1AD6');
$this->addSql('DROP TABLE card_template');
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260531150051 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// Hintergrund-PDF-Pfad + eigene Schriften. fonts wird nullable hinzugefügt und
// für bestehende Zeilen mit '[]' befüllt (sonst verletzt '' den JSON-CHECK), dann NOT NULL.
$this->addSql('ALTER TABLE card_template ADD background_path VARCHAR(255) DEFAULT NULL, ADD fonts JSON DEFAULT NULL');
$this->addSql("UPDATE card_template SET fonts = '[]' WHERE fonts IS NULL");
$this->addSql('ALTER TABLE card_template MODIFY fonts JSON NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE card_template DROP background_path, DROP fonts');
}
}

View File

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260602123707 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE product (id BINARY(16) NOT NULL, kind VARCHAR(20) NOT NULL, name VARCHAR(120) NOT NULL, description LONGTEXT DEFAULT NULL, width_mm DOUBLE PRECISION NOT NULL, height_mm DOUBLE PRECISION NOT NULL, bleed_mm DOUBLE PRECISION NOT NULL, safe_mm DOUBLE PRECISION NOT NULL, sides SMALLINT NOT NULL, nfc_enabled TINYINT NOT NULL, print_enabled TINYINT NOT NULL, active TINYINT NOT NULL, sort_order INT NOT NULL, created_at DATETIME NOT NULL, reseller_id BINARY(16) DEFAULT NULL, INDEX IDX_D34A04AD91E6A19D (reseller_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE product ADD CONSTRAINT FK_D34A04AD91E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)');
$this->addSql('ALTER TABLE card_template ADD product_id BINARY(16) DEFAULT NULL');
$this->addSql('ALTER TABLE card_template ADD CONSTRAINT FK_2E51D1004584665A FOREIGN KEY (product_id) REFERENCES product (id)');
$this->addSql('CREATE INDEX IDX_2E51D1004584665A ON card_template (product_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE product DROP FOREIGN KEY FK_D34A04AD91E6A19D');
$this->addSql('DROP TABLE product');
$this->addSql('ALTER TABLE card_template DROP FOREIGN KEY FK_2E51D1004584665A');
$this->addSql('DROP INDEX IDX_2E51D1004584665A ON card_template');
$this->addSql('ALTER TABLE card_template DROP product_id');
}
}

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260603105617 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE order_item (id BINARY(16) NOT NULL, quantity INT NOT NULL, order_id BINARY(16) NOT NULL, product_id BINARY(16) NOT NULL, employee_id BINARY(16) NOT NULL, INDEX IDX_52EA1F098D9F6D38 (order_id), INDEX IDX_52EA1F094584665A (product_id), INDEX IDX_52EA1F098C03F15C (employee_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE print_order (id BINARY(16) NOT NULL, number VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, note LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) NOT NULL, created_by_id BINARY(16) DEFAULT NULL, INDEX IDX_844C1953979B1AD6 (company_id), INDEX IDX_844C1953B03A8386 (created_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F098D9F6D38 FOREIGN KEY (order_id) REFERENCES print_order (id)');
$this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F094584665A FOREIGN KEY (product_id) REFERENCES product (id)');
$this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F098C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)');
$this->addSql('ALTER TABLE print_order ADD CONSTRAINT FK_844C1953979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE print_order ADD CONSTRAINT FK_844C1953B03A8386 FOREIGN KEY (created_by_id) REFERENCES employee (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F098D9F6D38');
$this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F094584665A');
$this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F098C03F15C');
$this->addSql('ALTER TABLE print_order DROP FOREIGN KEY FK_844C1953979B1AD6');
$this->addSql('ALTER TABLE print_order DROP FOREIGN KEY FK_844C1953B03A8386');
$this->addSql('DROP TABLE order_item');
$this->addSql('DROP TABLE print_order');
}
}

View File

@ -43,8 +43,7 @@ final class RenderCardCommand extends Command
return Command::FAILURE;
}
// Skalierungs-Test: irgendein vorhandenes Design der Firma, sonst Standard.
$template = $this->templates->findOneBy(['company' => $employee->getCompany()]) ?? $this->factory->default();
$template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default();
$pdf = $this->renderer->render($employee, $template);
$file = sprintf('/tmp/render-%s.pdf', gethostname());

View File

@ -6,20 +6,18 @@ use App\Entity\Company;
use App\Entity\ContactLink;
use App\Entity\Employee;
use App\Entity\Location;
use App\Entity\OrderItem;
use App\Entity\PlatformPlan;
use App\Entity\PrintOrder;
use App\Entity\Product;
use App\Entity\Reseller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'app:seed', description: 'Demo-Daten (Plattform/Reseller/Firmen/Mitarbeiter, alles als Employee).')]
#[AsCommand(name: 'app:seed', description: 'Legt Demo-Daten an (Admin, Reseller, Firmen, Mitarbeiter).')]
final class SeedCommand extends Command
{
public function __construct(
@ -32,115 +30,92 @@ final class SeedCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (null !== $this->em->getRepository(Employee::class)->findOneBy(['loginEmail' => 'admin@vcard4reseller.de'])) {
if (null !== $this->em->getRepository(User::class)->findOneBy(['email' => 'admin@vcard4reseller.de'])) {
$io->warning('Demo-Daten existieren bereits — übersprungen.');
return Command::SUCCESS;
}
$plan = (new PlatformPlan())->setName('Professional')->setSlug('professional')
// Plattform-Paket
$plan = (new PlatformPlan())
->setName('Professional')->setSlug('professional')
->setPricePerMonth(24900)->setMaxProfiles(500)->setMaxCompanies(8)
->setFeatures(['vcard', 'wallet', 'nfc', 'print']);
$this->em->persist($plan);
// Globaler Produktkatalog (Plattform-Produkte, für alle Reseller sichtbar)
$card = $this->product(null, Product::KIND_BUSINESS_CARD, 'Visitenkarte', 85.0, 55.0, 2.0, 4.0, 2, false, 0);
$this->product(null, Product::KIND_NAME_TAG, 'Namensschild', 90.0, 55.0, 0.0, 3.0, 1, false, 1);
$nfc = $this->product(null, Product::KIND_NFC_CARD, 'NFC-Karte', 85.6, 54.0, 2.0, 4.0, 2, true, 2);
// Plattform-Admin
$admin = (new User())->setEmail('admin@vcard4reseller.de')->setRoles([User::ROLE_PLATFORM_ADMIN]);
$admin->setPassword($this->hasher->hashPassword($admin, 'admin'));
$this->em->persist($admin);
// Plattform-Betreiber = Reseller mit isPlatform + Org-Firma + 2 Plattform-Admins
[$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true);
$this->staff($pOrg, 'Thomas', 'Peterson', 'admin@vcard4reseller.de', 'admin', Employee::ROLE_PLATFORM_ADMIN);
$this->staff($pOrg, 'Co', 'Admin', 'admin2@vcard4reseller.de', 'admin', Employee::ROLE_PLATFORM_ADMIN);
// Reseller „Demo Druckerei" + Org-Firma + Reseller-Admin + Kundenfirma
[$demo, $dOrg] = $this->reseller('Demo Druckerei', 'demo', $plan, false);
// Reseller-eigenes Produkt (nur im Mandanten der Demo Druckerei sichtbar)
$this->product($demo, Product::KIND_BUSINESS_CARD, 'Premium-Visitenkarte 90×50', 90.0, 50.0, 3.0, 4.0, 2, true, 0);
$this->staff($dOrg, 'Demo', 'Reseller', 'reseller@demo.de', 'reseller', Employee::ROLE_RESELLER_ADMIN);
$this->customer($demo, 'Muster GmbH', 'muster', 'firma@muster.de', 'firma', 'Erika', 'Mustermann', $card, $nfc);
// Reseller „Print Studio"
[$ps, $psOrg] = $this->reseller('Print Studio', 'printstudio', $plan, false);
$this->staff($psOrg, 'Print', 'Studio', 'reseller@printstudio.de', 'reseller', Employee::ROLE_RESELLER_ADMIN);
$this->customer($ps, 'Beispiel AG', 'beispiel', 'firma@beispiel.de', 'firma', 'Max', 'Beispiel');
// Zwei Reseller mit je einer Firma (Beweis der Mandantentrennung)
$this->createReseller($plan, 'Demo Druckerei', 'demo', 'reseller@demo.de', 'Muster GmbH', 'muster', 'firma@muster.de', 'Erika', 'Mustermann');
$this->createReseller($plan, 'Print Studio', 'printstudio', 'reseller@printstudio.de', 'Beispiel AG', 'beispiel', 'firma@beispiel.de', 'Max', 'Beispiel');
$this->em->flush();
$io->success('Demo-Daten angelegt (alles als Mitarbeiter mit optionalem Login).');
$io->table(['Rechtegruppe', 'Login', 'Passwort'], [
$io->success('Demo-Daten angelegt.');
$io->table(['Rolle', 'E-Mail', 'Passwort'], [
['Plattform-Admin', 'admin@vcard4reseller.de', 'admin'],
['Reseller-Admin', 'reseller@demo.de', 'reseller'],
['Reseller-Admin', 'reseller@printstudio.de', 'reseller'],
['Firmen-Admin', 'firma@muster.de', 'firma'],
['Firmen-Admin', 'firma@beispiel.de', 'firma'],
]);
return Command::SUCCESS;
}
private function product(?Reseller $reseller, string $kind, string $name, float $w, float $h, float $bleed, float $safe, int $sides, bool $nfc, int $sort): Product
{
$product = (new Product())->setReseller($reseller)->setKind($kind)->setName($name)
->setWidthMm($w)->setHeightMm($h)->setBleedMm($bleed)->setSafeMm($safe)
->setSides($sides)->setNfcEnabled($nfc)->setPrintEnabled(true)->setSortOrder($sort);
$this->em->persist($product);
return $product;
}
/** @return array{0: Reseller, 1: Company} Reseller + Org-Firma */
private function reseller(string $name, string $slug, PlatformPlan $plan, bool $isPlatform): array
{
$reseller = (new Reseller())->setName($name)->setSlug($slug)
->setPrimaryDomain($slug.'.vcard4reseller.de')->setPlatformPlan($plan)->setIsPlatform($isPlatform);
$org = (new Company())->setName($name)->setSlug($slug.'-team')->setReseller($reseller)->setIsResellerOrg(true);
private function createReseller(
PlatformPlan $plan,
string $resellerName,
string $resellerSlug,
string $resellerEmail,
string $companyName,
string $companySlug,
string $companyEmail,
string $firstName,
string $lastName,
): void {
$reseller = (new Reseller())
->setName($resellerName)->setSlug($resellerSlug)
->setPrimaryDomain($resellerSlug.'.vcard4reseller.de')
->setPlatformPlan($plan);
$this->em->persist($reseller);
$this->em->persist($org);
return [$reseller, $org];
}
$resellerAdmin = (new User())
->setEmail($resellerEmail)->setRoles([User::ROLE_RESELLER_ADMIN])->setReseller($reseller);
$resellerAdmin->setPassword($this->hasher->hashPassword($resellerAdmin, 'reseller'));
$this->em->persist($resellerAdmin);
private function staff(Company $company, string $first, string $last, string $email, string $pw, string $role): Employee
{
$e = (new Employee())->setFirstName($first)->setLastName($last)
->setSlug(strtolower($first.'-'.$last))->setCompany($company)
->setEmail($email)->setLoginEmail($email)->setRoles([$role]);
$e->setPassword($this->hasher->hashPassword($e, $pw));
$this->em->persist($e);
return $e;
}
private function customer(Reseller $reseller, string $companyName, string $companySlug, string $adminEmail, string $adminPw, string $first, string $last, ?Product $card = null, ?Product $nfc = null): void
{
$company = (new Company())->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true);
$company = (new Company())
->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true);
$this->em->persist($company);
$location = (new Location())->setName('Hauptsitz')->setStreet('Musterstr. 1')
->setPostalCode('10115')->setCity('Berlin')->setCountry('DE')->setCompany($company);
$companyAdmin = (new User())
->setEmail($companyEmail)->setRoles([User::ROLE_COMPANY_ADMIN])
->setReseller($reseller)->setCompany($company);
$companyAdmin->setPassword($this->hasher->hashPassword($companyAdmin, 'firma'));
$this->em->persist($companyAdmin);
$location = (new Location())
->setName('Hauptsitz')->setStreet('Musterstr. 1')->setPostalCode('10115')
->setCity('Berlin')->setCountry('DE')->setCompany($company);
$this->em->persist($location);
// Firmen-Admin (Mitarbeiter mit Login)
$admin = $this->staff($company, 'Firmen', 'Admin', $adminEmail, $adminPw, Employee::ROLE_COMPANY_ADMIN)
->setSlug('firmen-admin');
// Öffentliches Profil-Beispiel (ohne Login)
$employee = (new Employee())->setFirstName($first)->setLastName($last)
->setSlug(strtolower($first.'-'.$last))->setPosition('Geschäftsführung')
->setEmail(strtolower($first).'@'.$companySlug.'.de')->setPhone('+49 30 1234567')
->setCompany($company)->setLocation($location)->setSelfEditAllowed(true);
$employee = (new Employee())
->setFirstName($firstName)->setLastName($lastName)
->setSlug(strtolower($firstName.'-'.$lastName))
->setPosition('Geschäftsführung')->setEmail(strtolower($firstName).'@'.$companySlug.'.de')
->setPhone('+49 30 1234567')->setCompany($company)->setLocation($location)
->setSelfEditAllowed(true);
$this->em->persist($employee);
$link = (new ContactLink())->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($first))->setPosition(0);
$link = (new ContactLink())
->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($firstName))
->setPosition(0);
$employee->addContactLink($link);
$this->em->persist($link);
// Demo-Bestellung: 100 Visitenkarten (Profil) + 10 NFC-Karten (Admin)
if (null !== $card && null !== $nfc) {
$order = (new PrintOrder())->setCompany($company)->setCreatedBy($admin)
->setNote('Erstausstattung neues Team');
$order->addItem((new OrderItem())->setProduct($card)->setEmployee($employee)->setQuantity(100));
$order->addItem((new OrderItem())->setProduct($nfc)->setEmployee($admin)->setQuantity(10));
$this->em->persist($order);
}
}
}

View File

@ -4,9 +4,7 @@ namespace App\Controller;
use App\Entity\CardTemplate;
use App\Entity\Company;
use App\Entity\Product;
use App\Repository\CardTemplateRepository;
use App\Repository\ProductRepository;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
@ -33,7 +31,6 @@ final class CardAssetUploadController
{
public function __construct(
private readonly CardTemplateRepository $templates,
private readonly ProductRepository $products,
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
#[Autowire(service: 'card_assets.storage')]
@ -45,14 +42,13 @@ final class CardAssetUploadController
public function uploadBackground(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$product = $this->product($request->query->get('product'));
$file = $this->file($request);
if ('pdf' !== strtolower((string) $file->getClientOriginalExtension())) {
throw new BadRequestHttpException('Nur PDF erlaubt.');
}
$template = $this->getOrCreate($company, $product);
$key = $this->store($file, $company->getId(), $product->getId(), 'background', 'pdf');
$template = $this->getOrCreate($company);
$key = $this->store($file, $company->getId(), 'background', 'pdf');
$template->setBackgroundPath($key);
$this->em->persist($template);
$this->em->flush();
@ -61,11 +57,10 @@ final class CardAssetUploadController
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_get', methods: ['GET'])]
public function getBackground(string $id, Request $request): Response
public function getBackground(string $id): Response
{
$company = $this->company($id);
$product = $this->product($request->query->get('product'));
$key = $this->templates->findForCompanyAndProduct($company, $product)?->getBackgroundPath();
$key = $this->templates->findCardForCompany($company)?->getBackgroundPath();
if (!$key || !$this->cardAssets->fileExists($key)) {
throw new NotFoundHttpException('Kein Hintergrund-PDF.');
}
@ -76,11 +71,10 @@ final class CardAssetUploadController
}
#[Route('/api/companies/{id}/card-template/background', name: 'card_bg_delete', methods: ['DELETE'])]
public function deleteBackground(string $id, Request $request): JsonResponse
public function deleteBackground(string $id): JsonResponse
{
$company = $this->company($id);
$product = $this->product($request->query->get('product'));
$template = $this->templates->findForCompanyAndProduct($company, $product);
$template = $this->templates->findCardForCompany($company);
if ($template && $template->getBackgroundPath()) {
if ($this->cardAssets->fileExists($template->getBackgroundPath())) {
$this->cardAssets->delete($template->getBackgroundPath());
@ -96,7 +90,6 @@ final class CardAssetUploadController
public function uploadFont(string $id, Request $request): JsonResponse
{
$company = $this->company($id);
$product = $this->product($request->query->get('product'));
$file = $this->file($request);
$ext = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($ext, ['ttf', 'otf'], true)) {
@ -104,8 +97,8 @@ final class CardAssetUploadController
}
$family = trim((string) $request->request->get('family')) ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$template = $this->getOrCreate($company, $product);
$key = $this->store($file, $company->getId(), $product->getId(), 'font', $ext);
$template = $this->getOrCreate($company);
$key = $this->store($file, $company->getId(), 'font', $ext);
$template->addFont($family, $key);
$this->em->flush();
@ -123,39 +116,18 @@ final class CardAssetUploadController
}
/** Lädt die Datei in den Object-Storage und liefert den Key zurück. */
private function store(UploadedFile $file, Uuid $companyId, Uuid $productId, string $prefix, string $ext): string
private function store(UploadedFile $file, Uuid $companyId, string $prefix, string $ext): string
{
$key = sprintf('%s/%s/%s-%s.%s', $companyId->toRfc4122(), $productId->toRfc4122(), $prefix, bin2hex(random_bytes(4)), $ext);
$key = sprintf('%s/%s-%s.%s', $companyId->toRfc4122(), $prefix, bin2hex(random_bytes(4)), $ext);
$this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
return $key;
}
private function getOrCreate(Company $company, Product $product): CardTemplate
private function getOrCreate(Company $company): CardTemplate
{
return $this->templates->findForCompanyAndProduct($company, $product)
?? (new CardTemplate())->setCompany($company)->setProduct($product);
}
/** Lädt ein Produkt und prüft Sichtbarkeit (global oder eigener Reseller). */
private function product(?string $id): Product
{
if (null === $id || '' === $id) {
throw new NotFoundHttpException('Produkt nicht angegeben.');
}
$product = $this->products->find(Uuid::fromString($id));
if (!$product instanceof Product) {
throw new NotFoundHttpException('Produkt nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin() || $product->isGlobal()) {
return $product;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $product->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Produkt ist im eigenen Mandanten nicht verfügbar.');
}
return $product;
return $this->templates->findCardForCompany($company)
?? (new CardTemplate())->setCompany($company);
}
private function company(string $id): Company

View File

@ -3,14 +3,11 @@
namespace App\Controller;
use App\Entity\Employee;
use App\Entity\Product;
use App\Repository\CardTemplateRepository;
use App\Repository\EmployeeRepository;
use App\Repository\ProductRepository;
use App\Security\TenantContext;
use App\Service\CardPdfRenderer;
use App\Service\CardTemplateFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -27,7 +24,6 @@ final class CardPdfController
public function __construct(
private readonly EmployeeRepository $employees,
private readonly CardTemplateRepository $templates,
private readonly ProductRepository $products,
private readonly CardTemplateFactory $factory,
private readonly CardPdfRenderer $renderer,
private readonly TenantContext $tenant,
@ -35,7 +31,7 @@ final class CardPdfController
}
#[Route('/api/employees/{id}/card.pdf', name: 'employee_card_pdf', methods: ['GET'])]
public function __invoke(string $id, Request $request): Response
public function __invoke(string $id): Response
{
$employee = $this->employees->find(Uuid::fromString($id));
if (!$employee instanceof Employee) {
@ -43,15 +39,8 @@ final class CardPdfController
}
$this->assertAccess($employee);
$productId = $request->query->get('product');
$template = null;
if (null !== $productId && '' !== $productId) {
$product = $this->products->find(Uuid::fromString($productId));
if ($product instanceof Product) {
$template = $this->templates->findForCompanyAndProduct($employee->getCompany(), $product);
}
}
$pdf = $this->renderer->render($employee, $template ?? $this->factory->default());
$template = $this->templates->findCardForCompany($employee->getCompany()) ?? $this->factory->default();
$pdf = $this->renderer->render($employee, $template);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',

View File

@ -4,7 +4,6 @@ namespace App\Controller;
use App\Entity\CardTemplate;
use App\Entity\Company;
use App\Entity\Product;
use App\Repository\CardTemplateRepository;
use App\Security\TenantContext;
use App\Service\CardTemplateFactory;
@ -18,9 +17,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Lädt/speichert das Design einer Firma für ein bestimmtes Produkt (visueller Editor).
* Format (Maße/Beschnitt) wird vom Produkt geerbt; gibt falls noch kein Design
* existiert die Standardvorlage im Produktformat zurück.
* Lädt/speichert die Visitenkarten-Vorlage einer Firma für den visuellen Editor.
* Gibt falls noch keine Vorlage existiert die Standardvorlage zurück.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CardTemplateEditorController
@ -34,17 +32,12 @@ final class CardTemplateEditorController
}
#[Route('/api/companies/{id}/card-template', name: 'company_card_template_get', methods: ['GET'])]
public function get(string $id, Request $request): JsonResponse
public function get(string $id): JsonResponse
{
$company = $this->company($id);
$product = $this->product($request->query->get('product'));
$template = $this->templates->findForCompanyAndProduct($company, $product);
$template = $this->templates->findCardForCompany($company);
return new JsonResponse($this->serialize(
$template ?? $this->seedDefault($product),
null === $template,
$product,
));
return new JsonResponse($this->serialize($template ?? $this->factory->default(), null === $template));
}
#[Route('/api/companies/{id}/card-template', name: 'company_card_template_put', methods: ['PUT'])]
@ -52,38 +45,21 @@ final class CardTemplateEditorController
{
$company = $this->company($id);
$data = json_decode($request->getContent(), true) ?? [];
$product = $this->product($data['product'] ?? $request->query->get('product'));
$template = $this->templates->findForCompanyAndProduct($company, $product)
?? (new CardTemplate())->setCompany($company)->setProduct($product);
$template = $this->templates->findCardForCompany($company) ?? (new CardTemplate())->setCompany($company);
$template
->setName((string) ($data['name'] ?? $product->getName()))
// Format wird vom Produkt geerbt (nicht vom Client überschreibbar)
->setWidthMm($product->getWidthMm())
->setHeightMm($product->getHeightMm())
->setBleedMm($product->getBleedMm())
->setSafeMm($product->getSafeMm())
->setName((string) ($data['name'] ?? 'Standard'))
->setWidthMm((float) ($data['widthMm'] ?? 85))
->setHeightMm((float) ($data['heightMm'] ?? 55))
->setBleedMm((float) ($data['bleedMm'] ?? 2))
->setSafeMm((float) ($data['safeMm'] ?? 4))
->setFront(is_array($data['front'] ?? null) ? $data['front'] : [])
->setBack(is_array($data['back'] ?? null) ? $data['back'] : []);
$this->em->persist($template);
$this->em->flush();
return new JsonResponse($this->serialize($template, false, $product));
}
/** Standardvorlage als Startpunkt, an das Produktformat angepasst. */
private function seedDefault(Product $product): CardTemplate
{
$t = $this->factory->default();
$t->setProduct($product)
->setName($product->getName())
->setWidthMm($product->getWidthMm())
->setHeightMm($product->getHeightMm())
->setBleedMm($product->getBleedMm())
->setSafeMm($product->getSafeMm());
return $t;
return new JsonResponse($this->serialize($template, false));
}
private function company(string $id): Company
@ -107,40 +83,12 @@ final class CardTemplateEditorController
return $company;
}
/** Lädt ein Produkt und prüft Sichtbarkeit (global oder eigener Reseller). */
private function product(?string $id): Product
{
if (null === $id || '' === $id) {
throw new NotFoundHttpException('Produkt nicht angegeben.');
}
$product = $this->em->getRepository(Product::class)->find(Uuid::fromString($id));
if (!$product instanceof Product) {
throw new NotFoundHttpException('Produkt nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin() || $product->isGlobal()) {
return $product;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $product->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Produkt ist im eigenen Mandanten nicht verfügbar.');
}
return $product;
}
private function serialize(CardTemplate $t, bool $isDefault, Product $product): array
private function serialize(CardTemplate $t, bool $isDefault): array
{
return [
'id' => $isDefault ? null : (string) $t->getId(),
'isDefault' => $isDefault,
'name' => $t->getName(),
'product' => [
'id' => (string) $product->getId(),
'kind' => $product->getKind(),
'name' => $product->getName(),
'sides' => $product->getSides(),
'nfcEnabled' => $product->isNfcEnabled(),
],
'widthMm' => $t->getWidthMm(),
'heightMm' => $t->getHeightMm(),
'bleedMm' => $t->getBleedMm(),

View File

@ -1,85 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Security\TenantContext;
use App\Service\RoleService;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* „Arbeiten als" (Impersonation), nur absteigend & im eigenen Mandanten-Teilbaum:
* gibt ein JWT für den Ziel-Mitarbeiter aus (mit imp-Claim für Audit).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class ImpersonationController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly RoleService $roles,
private readonly TenantContext $tenant,
private readonly JWTTokenManagerInterface $jwt,
private readonly Security $security,
) {
}
#[Route('/api/impersonate/{id}', name: 'impersonate', methods: ['POST'])]
public function __invoke(string $id): JsonResponse
{
$target = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
if (!$target instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
if (!$target->hasLogin()) {
throw new BadRequestHttpException('Dieser Mitarbeiter hat kein Login.');
}
$this->assertInScope($target);
// Nur absteigend: Ziel-Ebene muss unter der eigenen liegen
if ($this->roles->levelOfRoles($target->getRoles()) >= $this->roles->actorLevel()) {
throw new AccessDeniedHttpException('Nur als niedrigere Ebene möglich.');
}
$actor = $this->security->getUser();
$impersonator = $actor instanceof Employee ? $actor->getUserIdentifier() : '';
$token = $this->jwt->createFromPayload($target, ['imp' => $impersonator]);
return new JsonResponse([
'token' => $token,
'actingAs' => [
'name' => trim($target->getFirstName().' '.$target->getLastName()),
'email' => $target->getLoginEmail(),
],
]);
}
private function assertInScope(Employee $target): void
{
if ($this->tenant->isPlatformAdmin()) {
return;
}
if (null !== $company = $this->tenant->getCompany()) {
if (!$target->getCompany()->getId()->equals($company->getId())) {
throw new AccessDeniedHttpException('Außerhalb der eigenen Firma.');
}
return;
}
if (null !== $reseller = $this->tenant->getReseller()) {
if ($target->getCompany()->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Außerhalb des eigenen Resellers.');
}
}
}
}

View File

@ -2,13 +2,13 @@
namespace App\Controller;
use App\Entity\Employee;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Infos zum eingeloggten Mitarbeiter für die SPA (Rollen + Mandantenkontext).
* Liefert Infos zum eingeloggten Nutzer für die SPA (Rollen + Mandantenkontext).
*/
final class MeController
{
@ -19,21 +19,20 @@ final class MeController
#[Route('/api/me', name: 'api_me', methods: ['GET'])]
public function __invoke(): JsonResponse
{
$employee = $this->security->getUser();
if (!$employee instanceof Employee) {
$user = $this->security->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Not authenticated'], 401);
}
$company = $employee->getCompany();
$reseller = $company->getReseller();
$reseller = $user->getReseller();
$company = $user->getCompany();
return new JsonResponse([
'id' => (string) $employee->getId(),
'email' => $employee->getLoginEmail(),
'name' => trim($employee->getFirstName().' '.$employee->getLastName()),
'roles' => $employee->getRoles(),
'reseller' => $reseller ? ['id' => (string) $reseller->getId(), 'name' => $reseller->getName(), 'isPlatform' => $reseller->isPlatform()] : null,
'company' => ['id' => (string) $company->getId(), 'name' => $company->getName(), 'isResellerOrg' => $company->isResellerOrg()],
'id' => (string) $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'reseller' => $reseller ? ['id' => (string) $reseller->getId(), 'name' => $reseller->getName()] : null,
'company' => $company ? ['id' => (string) $company->getId(), 'name' => $company->getName()] : null,
]);
}
}

View File

@ -1,246 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\OrderItem;
use App\Entity\PrintOrder;
use App\Entity\Product;
use App\Repository\EmployeeRepository;
use App\Repository\PrintOrderRepository;
use App\Repository\ProductRepository;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Bestellungen/Druckaufträge: Firma bestellt Produkte je Mitarbeiter,
* Reseller wickelt ab. Mandantengeprüft (KONZEPT §13 Bestellungen).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class OrderController
{
public function __construct(
private readonly PrintOrderRepository $orders,
private readonly ProductRepository $products,
private readonly EmployeeRepository $employees,
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/orders', name: 'orders_list', methods: ['GET'])]
public function list(): JsonResponse
{
$qb = $this->orders->createQueryBuilder('o')
->join('o.company', 'c')->addSelect('c')
->orderBy('o.createdAt', 'DESC');
if (!$this->tenant->isPlatformAdmin()) {
$reseller = $this->tenant->getReseller();
if (null === $reseller) {
return new JsonResponse(['member' => []]);
}
$qb->andWhere('c.reseller = :r')->setParameter('r', $reseller->getId(), 'uuid');
$company = $this->tenant->getCompany();
if (null !== $company) {
$qb->andWhere('c = :company')->setParameter('company', $company->getId(), 'uuid');
}
}
$member = array_map(fn (PrintOrder $o) => $this->serializeSummary($o), $qb->getQuery()->getResult());
return new JsonResponse(['member' => $member, 'totalItems' => count($member)]);
}
#[Route('/api/orders/{id}', name: 'orders_get', methods: ['GET'])]
public function get(string $id): JsonResponse
{
return new JsonResponse($this->serializeDetail($this->order($id)));
}
#[Route('/api/orders', name: 'orders_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$company = $this->resolveCompany($data['company'] ?? null);
$rawItems = is_array($data['items'] ?? null) ? $data['items'] : [];
if (0 === count($rawItems)) {
throw new BadRequestHttpException('Bestellung enthält keine Positionen.');
}
$order = (new PrintOrder())->setCompany($company)
->setNote(isset($data['note']) ? trim((string) $data['note']) ?: null : null)
->setCreatedBy($this->tenant->getEmployee());
foreach ($rawItems as $row) {
$product = $this->product((string) ($row['product'] ?? ''));
$employee = $this->employeeInCompany((string) ($row['employee'] ?? ''), $company);
$qty = (int) ($row['quantity'] ?? 0);
if ($qty < 1) {
throw new BadRequestHttpException('Menge muss mindestens 1 sein.');
}
$item = (new OrderItem())->setProduct($product)->setEmployee($employee)->setQuantity($qty);
$order->addItem($item);
}
$this->em->persist($order);
$this->em->flush();
return new JsonResponse($this->serializeDetail($order), 201);
}
#[Route('/api/orders/{id}/status', name: 'orders_status', methods: ['PATCH'])]
public function status(string $id, Request $request): JsonResponse
{
$order = $this->order($id);
$data = json_decode($request->getContent(), true) ?? [];
$target = (string) ($data['status'] ?? '');
if (!in_array($target, PrintOrder::STATUSES, true)) {
throw new BadRequestHttpException('Ungültiger Status.');
}
$isFulfiller = null === $this->tenant->getCompany(); // Reseller-/Plattform-Admin
if (PrintOrder::STATUS_CANCELLED === $target) {
// Firma darf nur stornieren, solange „neu"; Reseller/Plattform jederzeit
if (!$isFulfiller && PrintOrder::STATUS_NEW !== $order->getStatus()) {
throw new AccessDeniedHttpException('Stornieren nur möglich, solange die Bestellung neu ist.');
}
} elseif (!$isFulfiller) {
throw new AccessDeniedHttpException('Status wird vom Reseller (Druckshop) gesetzt.');
}
$order->setStatus($target);
$this->em->flush();
return new JsonResponse($this->serializeDetail($order));
}
// --- Hilfen ---
private function order(string $id): PrintOrder
{
$order = $this->orders->find(Uuid::fromString($id));
if (!$order instanceof PrintOrder) {
throw new NotFoundHttpException('Bestellung nicht gefunden.');
}
$this->assertScope($order->getCompany());
return $order;
}
/** Firma der Bestellung: Firmen-Admin = eigene; Reseller/Plattform = aus Body. */
private function resolveCompany(?string $companyId): Company
{
$own = $this->tenant->getCompany();
if (null !== $own) {
return $own;
}
if (null === $companyId || '' === $companyId) {
throw new BadRequestHttpException('Firma (company) erforderlich.');
}
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($companyId));
if (!$company instanceof Company) {
throw new NotFoundHttpException('Firma nicht gefunden.');
}
$this->assertScope($company);
return $company;
}
private function assertScope(Company $company): void
{
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
}
private function product(string $id): Product
{
$product = '' !== $id ? $this->products->find(Uuid::fromString($id)) : null;
if (!$product instanceof Product) {
throw new NotFoundHttpException('Produkt nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin() || $product->isGlobal()) {
return $product;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $product->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Produkt im eigenen Mandanten nicht verfügbar.');
}
return $product;
}
private function employeeInCompany(string $id, Company $company): Employee
{
$employee = '' !== $id ? $this->employees->find(Uuid::fromString($id)) : null;
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
if (!$employee->getCompany()->getId()->equals($company->getId())) {
throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zur bestellenden Firma.');
}
return $employee;
}
/** @return array<string, mixed> */
private function serializeSummary(PrintOrder $o): array
{
return [
'id' => (string) $o->getId(),
'number' => $o->getNumber(),
'status' => $o->getStatus(),
'company' => ['id' => (string) $o->getCompany()->getId(), 'name' => $o->getCompany()->getName()],
'itemCount' => $o->getItems()->count(),
'totalQuantity' => $o->getTotalQuantity(),
'createdAt' => $o->getCreatedAt()->format(\DATE_ATOM),
];
}
/** @return array<string, mixed> */
private function serializeDetail(PrintOrder $o): array
{
$items = [];
foreach ($o->getItems() as $item) {
$emp = $item->getEmployee();
$items[] = [
'id' => (string) $item->getId(),
'product' => ['id' => (string) $item->getProduct()->getId(), 'name' => $item->getProduct()->getName(), 'kind' => $item->getProduct()->getKind()],
'employee' => ['id' => (string) $emp->getId(), 'name' => trim($emp->getFirstName().' '.$emp->getLastName())],
'quantity' => $item->getQuantity(),
'pdfUrl' => sprintf('/api/employees/%s/card.pdf?product=%s', $emp->getId(), $item->getProduct()->getId()),
];
}
return [
'id' => (string) $o->getId(),
'number' => $o->getNumber(),
'status' => $o->getStatus(),
'note' => $o->getNote(),
'company' => ['id' => (string) $o->getCompany()->getId(), 'name' => $o->getCompany()->getName()],
'createdBy' => $o->getCreatedBy() ? trim($o->getCreatedBy()->getFirstName().' '.$o->getCreatedBy()->getLastName()) : null,
'createdAt' => $o->getCreatedAt()->format(\DATE_ATOM),
'totalQuantity' => $o->getTotalQuantity(),
'items' => $items,
];
}
}

View File

@ -5,7 +5,6 @@ namespace App\Controller;
use App\Entity\Employee;
use App\Repository\EmployeeRepository;
use App\Service\VCardBuilder;
use App\Service\WalletService;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
@ -26,7 +25,7 @@ final class PublicProfileController extends AbstractController
}
#[Route('/p/{companySlug}/{slug}', name: 'public_profile', methods: ['GET'])]
public function show(string $companySlug, string $slug, WalletService $wallet): Response
public function show(string $companySlug, string $slug): Response
{
$employee = $this->resolve($companySlug, $slug);
@ -34,8 +33,6 @@ final class PublicProfileController extends AbstractController
'e' => $employee,
'profileUrl' => $this->profileUrl($employee),
'shareUrl' => $this->shareUrl($employee),
'walletEnabled' => null !== $employee->getShortCode()
&& ($wallet->isAppleConfigured() || $wallet->isGoogleConfigured()),
]);
}

View File

@ -2,10 +2,9 @@
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\PlatformPlan;
use App\Entity\Reseller;
use App\Entity\User;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
@ -15,8 +14,8 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Legt einen Reseller an: Reseller + Org-Firma (für sein Personal) + optional
* einen Admin-Mitarbeiter mit Login. Nur für Plattform-Admins.
* Legt einen Reseller an optional zusammen mit seinem Admin-Benutzer,
* damit sich der Reseller direkt einloggen kann. Nur für Plattform-Admins.
*/
#[IsGranted('ROLE_PLATFORM_ADMIN')]
final class ResellerProvisioningController
@ -49,32 +48,19 @@ final class ResellerProvisioningController
}
}
// Org-Firma des Resellers (beherbergt dessen Personal)
$orgCompany = (new Company())
->setName($name)
->setSlug($slug.'-team')
->setReseller($reseller)
->setIsResellerOrg(true);
$this->em->persist($reseller);
$this->em->persist($orgCompany);
$adminEmail = trim((string) ($d['adminEmail'] ?? ''));
$adminPassword = (string) ($d['adminPassword'] ?? '');
$admin = null;
if ('' !== $adminEmail && '' !== $adminPassword) {
$admin = (new Employee())
->setFirstName((string) ($d['adminFirstName'] ?? 'Admin'))
->setLastName((string) ($d['adminLastName'] ?? $name))
->setSlug('admin')
->setCompany($orgCompany)
->setLoginEmail($adminEmail)
->setRoles([Employee::ROLE_RESELLER_ADMIN]);
$admin->setEmail($adminEmail);
$admin = (new User())
->setEmail($adminEmail)
->setRoles([User::ROLE_RESELLER_ADMIN])
->setReseller($reseller);
$admin->setPassword($this->hasher->hashPassword($admin, $adminPassword));
}
try {
$this->em->persist($reseller);
if ($admin) {
$this->em->persist($admin);
}

View File

@ -1,164 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Security\TenantContext;
use App\Service\RoleService;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Logins/Rechtegruppen werden pro Mitarbeiter vergeben (KONZEPT §2 + Merge).
* Delegiert & scope-geprüft über RoleService.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class UserAdminController
{
private const ROLE_TO_GROUP = [
Employee::ROLE_PLATFORM_ADMIN => 'platform_admin',
Employee::ROLE_RESELLER_ADMIN => 'reseller_admin',
Employee::ROLE_COMPANY_ADMIN => 'company_admin',
Employee::ROLE_EMPLOYEE => 'employee',
];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly RoleService $roles,
private readonly TenantContext $tenant,
private readonly UserPasswordHasherInterface $hasher,
) {
}
#[Route('/api/users/assignable-groups', name: 'users_assignable_groups', methods: ['GET'])]
public function assignableGroups(): JsonResponse
{
return new JsonResponse(['groups' => $this->roles->assignableGroups()]);
}
/** Übersicht aller Mitarbeiter mit Login (mandantengescoped). */
#[Route('/api/users', name: 'users_list', methods: ['GET'])]
public function list(): JsonResponse
{
$qb = $this->em->getRepository(Employee::class)->createQueryBuilder('e')
->join('e.company', 'c')
->andWhere('e.loginEmail IS NOT NULL');
if (!$this->tenant->isPlatformAdmin()) {
if (null !== $company = $this->tenant->getCompany()) {
$qb->andWhere('c.id = :cid')->setParameter('cid', $company->getId(), 'uuid');
} elseif (null !== $reseller = $this->tenant->getReseller()) {
$qb->andWhere('c.reseller = :rid')->setParameter('rid', $reseller->getId(), 'uuid');
}
}
$rows = array_map($this->serialize(...), $qb->getQuery()->getResult());
return new JsonResponse(['member' => $rows, 'totalItems' => count($rows)]);
}
/** Rechtegruppe setzen (+ optional Login/Passwort) — Hochstufen via Rolle. */
#[Route('/api/employees/{id}/access', name: 'employee_access', methods: ['PATCH'])]
public function setAccess(string $id, Request $request): JsonResponse
{
$employee = $this->employee($id);
$d = json_decode($request->getContent(), true) ?? [];
$group = (string) ($d['group'] ?? '');
$this->roles->assertCanAssign($group, $employee->getCompany()->getReseller(), $employee->getCompany());
$employee->setRoles([$this->roles->roleForGroup($group)]);
// Passwort optional: setzt/aktiviert das Login
$password = (string) ($d['password'] ?? '');
if ('' !== $password) {
$loginEmail = trim((string) ($d['loginEmail'] ?? $employee->getLoginEmail() ?? $employee->getEmail() ?? ''));
if ('' === $loginEmail) {
throw new BadRequestHttpException('Login-E-Mail erforderlich (oder Kontakt-E-Mail setzen).');
}
$employee->setLoginEmail($loginEmail);
$employee->setPassword($this->hasher->hashPassword($employee, $password));
}
try {
$this->em->flush();
} catch (UniqueConstraintViolationException) {
throw new BadRequestHttpException('Login-E-Mail bereits vergeben.');
}
return new JsonResponse($this->serialize($employee));
}
/** Login entziehen → zurück auf reinen Kontakt. */
#[Route('/api/employees/{id}/login', name: 'employee_login_revoke', methods: ['DELETE'])]
public function revoke(string $id): JsonResponse
{
$employee = $this->employee($id);
if ($this->tenant->getEmployee() === $employee) {
throw new AccessDeniedHttpException('Eigenen Zugang nicht entziehen.');
}
$employee->setLoginEmail(null)->setPassword(null)->setRoles([Employee::ROLE_CONTACT]);
$this->em->flush();
return new JsonResponse(null, 204);
}
private function employee(string $id): Employee
{
$employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
$this->assertInScope($employee);
return $employee;
}
private function assertInScope(Employee $employee): void
{
if ($this->tenant->isPlatformAdmin()) {
return;
}
if (null !== $company = $this->tenant->getCompany()) {
if (!$employee->getCompany()->getId()->equals($company->getId())) {
throw new AccessDeniedHttpException('Außerhalb der eigenen Firma.');
}
return;
}
if (null !== $reseller = $this->tenant->getReseller()) {
if ($employee->getCompany()->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Außerhalb des eigenen Resellers.');
}
}
}
private function serialize(Employee $e): array
{
$group = 'employee';
foreach (self::ROLE_TO_GROUP as $role => $g) {
if (in_array($role, $e->getRoles(), true)) {
$group = $g;
break;
}
}
$company = $e->getCompany();
return [
'employeeId' => (string) $e->getId(),
'email' => $e->getLoginEmail(),
'name' => trim($e->getFirstName().' '.$e->getLastName()),
'group' => $group,
'company' => ['id' => (string) $company->getId(), 'name' => $company->getName()],
'reseller' => $company->getReseller() ? ['id' => (string) $company->getReseller()->getId(), 'name' => $company->getReseller()->getName()] : null,
];
}
}

View File

@ -1,99 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Repository\EmployeeRepository;
use App\Service\WalletService;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Writer\PngWriter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Öffentliche Wallet-Endpunkte: QR/Landing zum Hinzufügen der Visitenkarte
* in Apple/Google Wallet. Adressierung über den stabilen Kurz-Code (shortCode).
*/
final class WalletController extends AbstractController
{
public function __construct(
private readonly EmployeeRepository $employees,
private readonly WalletService $wallet,
) {
}
#[Route('/w/{code}', name: 'wallet_landing', methods: ['GET'])]
public function landing(string $code, Request $request): Response
{
$employee = $this->resolve($code);
$ua = (string) $request->headers->get('User-Agent', '');
$isApple = (bool) preg_match('/iPhone|iPad|iPod|Macintosh/i', $ua);
return $this->render('public/wallet.html.twig', [
'e' => $employee,
'code' => $code,
'appleEnabled' => $this->wallet->isAppleConfigured(),
'googleEnabled' => $this->wallet->isGoogleConfigured(),
'preferApple' => $isApple,
]);
}
#[Route('/w/{code}/qr.png', name: 'wallet_qr', methods: ['GET'])]
public function qr(string $code): Response
{
$this->resolve($code);
$url = $this->generateUrl('wallet_landing', ['code' => $code], UrlGeneratorInterface::ABSOLUTE_URL);
$result = (new Builder(
writer: new PngWriter(),
data: $url,
encoding: new Encoding('UTF-8'),
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
size: 320,
margin: 12,
))->build();
return new Response($result->getString(), 200, ['Content-Type' => $result->getMimeType()]);
}
#[Route('/w/{code}/apple.pkpass', name: 'wallet_apple', methods: ['GET'])]
public function apple(string $code): Response
{
$employee = $this->resolve($code);
if (!$this->wallet->isAppleConfigured()) {
throw $this->createNotFoundException('Apple Wallet ist nicht konfiguriert.');
}
return new Response($this->wallet->applePkpass($employee), 200, [
'Content-Type' => 'application/vnd.apple.pkpass',
'Content-Disposition' => sprintf('attachment; filename="%s.pkpass"', $employee->getSlug()),
]);
}
#[Route('/w/{code}/google', name: 'wallet_google', methods: ['GET'])]
public function google(string $code): Response
{
$employee = $this->resolve($code);
if (!$this->wallet->isGoogleConfigured()) {
throw $this->createNotFoundException('Google Wallet ist nicht konfiguriert.');
}
return new RedirectResponse($this->wallet->googleSaveUrl($employee), 302);
}
private function resolve(string $code): Employee
{
$employee = $this->employees->findByShortCode($code);
if (null === $employee) {
throw $this->createNotFoundException('Unbekannter Code.');
}
return $employee;
}
}

View File

@ -11,7 +11,6 @@ use App\Entity\ContactLink;
use App\Entity\Domain;
use App\Entity\Employee;
use App\Entity\Location;
use App\Entity\Product;
use App\Security\TenantContext;
use Doctrine\ORM\QueryBuilder;
@ -65,14 +64,6 @@ final class TenantExtension implements QueryCollectionExtensionInterface, QueryI
$alias = $qb->getRootAliases()[0];
// Produkte: globale (reseller IS NULL) + eigene des Resellers, ohne Company-Beschränkung.
if (Product::class === $resourceClass) {
$qb->andWhere("$alias.reseller = :tenant_reseller OR $alias.reseller IS NULL")
->setParameter('tenant_reseller', $reseller->getId(), 'uuid');
return;
}
// Join-Pfad zur Reseller-/Company-Spalte je nach Entität
[$companyAlias, $resellerExpr] = match ($resourceClass) {
Company::class => [$alias, "$alias.reseller"],

View File

@ -59,10 +59,6 @@ class CardTemplate implements ResellerOwnedInterface
#[ORM\ManyToOne(targetEntity: Company::class)]
private ?Company $company = null;
/** Produkt, für das dieses Design erstellt wurde (Format wird vom Produkt geerbt). */
#[ORM\ManyToOne(targetEntity: Product::class)]
private ?Product $product = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
@ -224,18 +220,6 @@ class CardTemplate implements ResellerOwnedInterface
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): self
{
$this->product = $product;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company?->getReseller();

View File

@ -45,10 +45,6 @@ class Company implements ResellerOwnedInterface
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Org-Firma des Resellers (beherbergt dessen Personal), keine Kundenfirma. */
#[ORM\Column]
private bool $isResellerOrg = false;
/** Erlaubt Mitarbeiter-Self-Service generell (zusätzlich pro Employee freizugeben). */
#[ORM\Column]
private bool $selfEditEnabled = false;
@ -125,18 +121,6 @@ class Company implements ResellerOwnedInterface
return $this;
}
public function isResellerOrg(): bool
{
return $this->isResellerOrg;
}
public function setIsResellerOrg(bool $isResellerOrg): self
{
$this->isResellerOrg = $isResellerOrg;
return $this;
}
public function isSelfEditEnabled(): bool
{
return $this->selfEditEnabled;

View File

@ -8,31 +8,17 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\Component\Uid\Uuid;
/**
* Mitarbeiterprofil Single Source of Truth für alle Ausgabekanäle UND
* Login-Identität (Auth). Login/Passwort sind optional: ein Mitarbeiter kann
* ohne eigenes Login existieren (Visitenkarte/NFC) und bei Bedarf eine
* Rechtegruppe + Login erhalten.
* Mitarbeiterprofil Single Source of Truth für alle Ausgabekanäle.
*/
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_employee_company_slug', fields: ['company', 'slug'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_shortcode', fields: ['shortCode'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_login_email', fields: ['loginEmail'])]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthenticatedUserInterface
class Employee implements ResellerOwnedInterface
{
public const ROLE_PLATFORM_ADMIN = 'ROLE_PLATFORM_ADMIN';
public const ROLE_RESELLER_ADMIN = 'ROLE_RESELLER_ADMIN';
public const ROLE_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN';
public const ROLE_EMPLOYEE = 'ROLE_EMPLOYEE';
/** Basis-Rolle: reines Profil/Kontakt (Visitenkarte), kein Login nötig. */
public const ROLE_CONTACT = 'ROLE_CONTACT';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
@ -97,17 +83,8 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
#[ORM\ManyToOne(targetEntity: Location::class)]
private ?Location $location = null;
// --- Login/Auth (optional) ---
/** Eindeutige Login-E-Mail; null = kein Login. */
#[ORM\Column(length: 180, nullable: true)]
private ?string $loginEmail = null;
#[ORM\Column(nullable: true)]
private ?string $password = null;
/** @var string[] Rechtegruppe(n); Standard = ROLE_CONTACT (reines Profil). */
#[ORM\Column(type: 'json')]
private array $roles = [self::ROLE_CONTACT];
#[ORM\OneToOne(targetEntity: User::class, mappedBy: 'employee')]
private ?User $user = null;
/** @var Collection<int, ContactLink> */
#[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)]
@ -340,65 +317,18 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
return $this;
}
// --- Login/Auth ---
public function getLoginEmail(): ?string
public function getUser(): ?User
{
return $this->loginEmail;
return $this->user;
}
public function setLoginEmail(?string $loginEmail): self
public function setUser(?User $user): self
{
$this->loginEmail = $loginEmail;
$this->user = $user;
return $this;
}
public function hasLogin(): bool
{
return null !== $this->loginEmail && null !== $this->password;
}
#[Ignore]
public function getUserIdentifier(): string
{
return (string) $this->loginEmail;
}
/** @return string[] */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
/** @param string[] $roles */
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
#[Ignore]
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(?string $password): self
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
}
/** @return Collection<int, ContactLink> */
public function getContactLinks(): Collection
{

View File

@ -1,92 +0,0 @@
<?php
namespace App\Entity;
use App\Repository\OrderItemRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Eine Position einer Bestellung: ein Produkt für einen Mitarbeiter in bestimmter Auflage.
*/
#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
class OrderItem
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: PrintOrder::class, inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false)]
private PrintOrder $order;
#[ORM\ManyToOne(targetEntity: Product::class)]
#[ORM\JoinColumn(nullable: false)]
private Product $product;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
private Employee $employee;
#[ORM\Column(type: 'integer')]
private int $quantity = 1;
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getOrder(): PrintOrder
{
return $this->order;
}
public function setOrder(PrintOrder $order): self
{
$this->order = $order;
return $this;
}
public function getProduct(): Product
{
return $this->product;
}
public function setProduct(Product $product): self
{
$this->product = $product;
return $this;
}
public function getEmployee(): Employee
{
return $this->employee;
}
public function setEmployee(Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): self
{
$this->quantity = max(1, $quantity);
return $this;
}
}

View File

@ -1,164 +0,0 @@
<?php
namespace App\Entity;
use App\Repository\PrintOrderRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Bestellung/Druckauftrag einer Firma (KONZEPT §13 Bestellungen).
* Die Firma bestellt Produkte je Mitarbeiter; der Reseller wickelt ab.
*/
#[ORM\Entity(repositoryClass: PrintOrderRepository::class)]
#[ORM\Table(name: 'print_order')]
class PrintOrder implements ResellerOwnedInterface
{
public const STATUS_NEW = 'new';
public const STATUS_IN_PRODUCTION = 'in_production';
public const STATUS_SHIPPED = 'shipped';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUSES = [
self::STATUS_NEW,
self::STATUS_IN_PRODUCTION,
self::STATUS_SHIPPED,
self::STATUS_COMPLETED,
self::STATUS_CANCELLED,
];
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 20)]
private string $number;
#[ORM\ManyToOne(targetEntity: Company::class)]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_NEW;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $note = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Employee $createdBy = null;
/** @var Collection<int, OrderItem> */
#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $items;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
$this->items = new ArrayCollection();
$this->number = 'B-'.strtoupper(bin2hex(random_bytes(3)));
}
public function getId(): Uuid
{
return $this->id;
}
public function getNumber(): string
{
return $this->number;
}
public function getCompany(): Company
{
return $this->company;
}
public function setCompany(Company $company): self
{
$this->company = $company;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getNote(): ?string
{
return $this->note;
}
public function setNote(?string $note): self
{
$this->note = $note;
return $this;
}
public function getCreatedBy(): ?Employee
{
return $this->createdBy;
}
public function setCreatedBy(?Employee $createdBy): self
{
$this->createdBy = $createdBy;
return $this;
}
/** @return Collection<int, OrderItem> */
public function getItems(): Collection
{
return $this->items;
}
public function addItem(OrderItem $item): self
{
if (!$this->items->contains($item)) {
$this->items->add($item);
$item->setOrder($this);
}
return $this;
}
/** Gesamtauflage aller Positionen. */
public function getTotalQuantity(): int
{
$sum = 0;
foreach ($this->items as $item) {
$sum += $item->getQuantity();
}
return $sum;
}
public function getReseller(): ?Reseller
{
return $this->company->getReseller();
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@ -1,272 +0,0 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ProductRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Produkttyp im Katalog (Visitenkarte, Namensschild, NFC-Karte ).
* reseller = null globales Plattform-Produkt (für alle sichtbar).
* reseller != null Reseller-eigenes Produkt (nur im eigenen Mandanten sichtbar).
* Firmen wählen aus sichtbaren Produkten, legen aber keine an (siehe KONZEPT §13).
*/
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ApiResource(
operations: [
// Lesen: auch Firmen-Admins (Tenant-Extension liefert globale + eigene)
new GetCollection(security: "is_granted('ROLE_COMPANY_ADMIN')"),
new Get(security: "is_granted('ROLE_COMPANY_ADMIN')"),
// Anlegen: Reseller-Admins (eigene) und Plattform-Admins (global)
new Post(security: "is_granted('ROLE_RESELLER_ADMIN')"),
// Ändern/Löschen: nur Eigentümer (Voter)
new Patch(security: "is_granted('PRODUCT_EDIT', object)"),
new Delete(security: "is_granted('PRODUCT_EDIT', object)"),
],
)]
class Product implements ResellerOwnedInterface
{
public const KIND_BUSINESS_CARD = 'business_card';
public const KIND_NAME_TAG = 'name_tag';
public const KIND_NFC_CARD = 'nfc_card';
public const KINDS = [
self::KIND_BUSINESS_CARD,
self::KIND_NAME_TAG,
self::KIND_NFC_CARD,
];
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
/** null = globales Plattform-Produkt. */
#[ORM\ManyToOne(targetEntity: Reseller::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Reseller $reseller = null;
#[ORM\Column(length: 20)]
private string $kind = self::KIND_BUSINESS_CARD;
#[ORM\Column(length: 120)]
private string $name = '';
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'float')]
private float $widthMm = 85.0;
#[ORM\Column(type: 'float')]
private float $heightMm = 55.0;
#[ORM\Column(type: 'float')]
private float $bleedMm = 2.0;
#[ORM\Column(type: 'float')]
private float $safeMm = 4.0;
#[ORM\Column(type: 'smallint')]
private int $sides = 2;
#[ORM\Column]
private bool $nfcEnabled = false;
#[ORM\Column]
private bool $printEnabled = true;
#[ORM\Column]
private bool $active = true;
#[ORM\Column(type: 'integer')]
private int $sortOrder = 0;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid
{
return $this->id;
}
public function getReseller(): ?Reseller
{
return $this->reseller;
}
public function setReseller(?Reseller $reseller): self
{
$this->reseller = $reseller;
return $this;
}
/** Globales Plattform-Produkt? (serialisiert als "isGlobal"). */
public function isGlobal(): bool
{
return null === $this->reseller;
}
public function getKind(): string
{
return $this->kind;
}
public function setKind(string $kind): self
{
$this->kind = $kind;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
public function getWidthMm(): float
{
return $this->widthMm;
}
public function setWidthMm(float $widthMm): self
{
$this->widthMm = $widthMm;
return $this;
}
public function getHeightMm(): float
{
return $this->heightMm;
}
public function setHeightMm(float $heightMm): self
{
$this->heightMm = $heightMm;
return $this;
}
public function getBleedMm(): float
{
return $this->bleedMm;
}
public function setBleedMm(float $bleedMm): self
{
$this->bleedMm = $bleedMm;
return $this;
}
public function getSafeMm(): float
{
return $this->safeMm;
}
public function setSafeMm(float $safeMm): self
{
$this->safeMm = $safeMm;
return $this;
}
public function getSides(): int
{
return $this->sides;
}
public function setSides(int $sides): self
{
$this->sides = max(1, min(2, $sides));
return $this;
}
public function isNfcEnabled(): bool
{
return $this->nfcEnabled;
}
public function setNfcEnabled(bool $nfcEnabled): self
{
$this->nfcEnabled = $nfcEnabled;
return $this;
}
public function isPrintEnabled(): bool
{
return $this->printEnabled;
}
public function setPrintEnabled(bool $printEnabled): self
{
$this->printEnabled = $printEnabled;
return $this;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): self
{
$this->active = $active;
return $this;
}
public function getSortOrder(): int
{
return $this->sortOrder;
}
public function setSortOrder(int $sortOrder): self
{
$this->sortOrder = $sortOrder;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@ -34,10 +34,6 @@ class Reseller
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Markiert den Plattform-Betreiber (selbst ein Reseller). */
#[ORM\Column]
private bool $isPlatform = false;
/** Branding-Defaults (Logo, Farben, Fonts). */
#[ORM\Column(type: 'json')]
private array $brandingConfig = [];
@ -112,18 +108,6 @@ class Reseller
return $this;
}
public function isPlatform(): bool
{
return $this->isPlatform;
}
public function setIsPlatform(bool $isPlatform): self
{
$this->isPlatform = $isPlatform;
return $this;
}
public function getBrandingConfig(): array
{
return $this->brandingConfig;

183
backend/src/Entity/User.php Normal file
View File

@ -0,0 +1,183 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'uniq_user_email', fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
public const ROLE_PLATFORM_ADMIN = 'ROLE_PLATFORM_ADMIN';
public const ROLE_RESELLER_ADMIN = 'ROLE_RESELLER_ADMIN';
public const ROLE_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN';
public const ROLE_EMPLOYEE = 'ROLE_EMPLOYEE';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 180)]
private string $email;
/** @var string[] */
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private string $password;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Reseller-Kontext (für Reseller-Admins). */
#[ORM\ManyToOne(targetEntity: Reseller::class)]
private ?Reseller $reseller = null;
/** Firmen-Kontext (für Firmen-Admins). */
#[ORM\ManyToOne(targetEntity: Company::class)]
private ?Company $company = null;
/** Verknüpftes Mitarbeiterprofil (für Self-Service). */
#[ORM\OneToOne(targetEntity: Employee::class, inversedBy: 'user')]
private ?Employee $employee = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastLoginAt = null;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getUserIdentifier(): string
{
return $this->email;
}
/** @return string[] */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
/** @param string[] $roles */
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->reseller;
}
public function setReseller(?Reseller $reseller): self
{
$this->reseller = $reseller;
return $this;
}
public function getCompany(): ?Company
{
return $this->company;
}
public function setCompany(?Company $company): self
{
$this->company = $company;
return $this;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getLastLoginAt(): ?\DateTimeImmutable
{
return $this->lastLoginAt;
}
public function setLastLoginAt(?\DateTimeImmutable $lastLoginAt): self
{
$this->lastLoginAt = $lastLoginAt;
return $this;
}
public function eraseCredentials(): void
{
// ggf. temporäre, sensible Daten löschen
}
}

View File

@ -4,7 +4,6 @@ namespace App\Repository;
use App\Entity\CardTemplate;
use App\Entity\Company;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -18,9 +17,9 @@ class CardTemplateRepository extends ServiceEntityRepository
parent::__construct($registry, CardTemplate::class);
}
/** Design einer Firma für ein bestimmtes Produkt, falls vorhanden. */
public function findForCompanyAndProduct(Company $company, Product $product): ?CardTemplate
/** Vorlage einer Firma (Karten-Typ), falls vorhanden. */
public function findCardForCompany(Company $company): ?CardTemplate
{
return $this->findOneBy(['company' => $company, 'product' => $product]);
return $this->findOneBy(['company' => $company, 'type' => CardTemplate::TYPE_CARD]);
}
}

View File

@ -5,30 +5,17 @@ namespace App\Repository;
use App\Entity\Employee;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<Employee>
*/
class EmployeeRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
class EmployeeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Employee::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof Employee) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
/**
* Lädt ein öffentlich sichtbares (aktives) Profil anhand Firmen- und
* Mitarbeiter-Slug. Nicht mandantengefiltert diese Seiten sind öffentlich.

View File

@ -1,18 +0,0 @@
<?php
namespace App\Repository;
use App\Entity\OrderItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<OrderItem>
*/
class OrderItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, OrderItem::class);
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Repository;
use App\Entity\PrintOrder;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PrintOrder>
*/
class PrintOrderRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PrintOrder::class);
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Product>
*/
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}

View File

@ -3,17 +3,13 @@
namespace App\Security;
use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\Reseller;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Liefert den Mandantenkontext (Reseller/Company) des eingeloggten Mitarbeiters.
* Alles wird aus dem Employee abgeleitet (Employee = Login-Identität):
* - Reseller = company.reseller
* - Company = die eigene Firma NUR für Firmen-Admins/Mitarbeiter; Reseller-/
* Plattform-Admins arbeiten reseller-weit (company = null).
* Plattform-Admins sehen alles.
* Liefert den Mandantenkontext (Reseller/Company) des eingeloggten Nutzers.
* Plattform-Admins haben keinen Mandantenkontext und sehen alles.
*/
final class TenantContext
{
@ -21,30 +17,22 @@ final class TenantContext
{
}
public function getEmployee(): ?Employee
{
$user = $this->security->getUser();
return $user instanceof Employee ? $user : null;
}
public function isPlatformAdmin(): bool
{
return $this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN);
return $this->security->isGranted(User::ROLE_PLATFORM_ADMIN);
}
public function getReseller(): ?Reseller
{
return $this->getEmployee()?->getCompany()->getReseller();
$user = $this->security->getUser();
return $user instanceof User ? $user->getReseller() : null;
}
public function getCompany(): ?Company
{
// Reseller-/Plattform-Admins sind reseller-weit unterwegs → keine Company-Einschränkung
if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) {
return null;
}
$user = $this->security->getUser();
return $this->getEmployee()?->getCompany();
return $user instanceof User ? $user->getCompany() : null;
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Security\Voter;
use App\Entity\Product;
use App\Security\TenantContext;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Ändern/Löschen eines Produkts darf nur sein Eigentümer:
* - Plattform-Admin alle (inkl. globale).
* - Reseller-Admin nur die eigenen (reseller == eigener Reseller); globale + fremde sind read-only.
*/
final class ProductVoter extends Voter
{
public const EDIT = 'PRODUCT_EDIT';
public function __construct(private readonly TenantContext $tenant)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::EDIT === $attribute && $subject instanceof Product;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
\assert($subject instanceof Product);
if ($this->tenant->isPlatformAdmin()) {
return true;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $subject->isGlobal()) {
return false;
}
return $subject->getReseller()?->getId()->equals($reseller->getId()) === true;
}
}

View File

@ -1,132 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\Reseller;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Setzt das delegierte Rechte-Konzept durch (KONZEPT §2): Ein Akteur darf nur
* Rollen der eigenen Ebene und nur im eigenen Mandanten-Teilbaum vergeben.
*/
final class RoleService
{
/** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */
private const GROUPS = [
'platform_admin' => ['role' => Employee::ROLE_PLATFORM_ADMIN, 'level' => 4],
'reseller_admin' => ['role' => Employee::ROLE_RESELLER_ADMIN, 'level' => 3],
'company_admin' => ['role' => Employee::ROLE_COMPANY_ADMIN, 'level' => 2],
'employee' => ['role' => Employee::ROLE_EMPLOYEE, 'level' => 1],
'contact' => ['role' => Employee::ROLE_CONTACT, 'level' => 0],
];
/** Mindest-Ebene zum Verwalten anderer (Firmen-Admin). */
private const MANAGE_LEVEL = 2;
public function __construct(private readonly Security $security)
{
}
public function isValidGroup(string $group): bool
{
return isset(self::GROUPS[$group]);
}
public function roleForGroup(string $group): string
{
return self::GROUPS[$group]['role'];
}
/** Höchste Ebene des aktuell eingeloggten Akteurs (respektiert Rollen-Hierarchie). */
public function actorLevel(): int
{
if ($this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN)) {
return 4;
}
if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) {
return 3;
}
if ($this->security->isGranted(Employee::ROLE_COMPANY_ADMIN)) {
return 2;
}
if ($this->security->isGranted(Employee::ROLE_EMPLOYEE)) {
return 1;
}
return 0;
}
/** Höchste Ebene aus einer Rollenliste (z. B. eines Ziel-Mitarbeiters). */
public function levelOfRoles(array $roles): int
{
$level = 0;
foreach (self::GROUPS as $g) {
if (in_array($g['role'], $roles, true)) {
$level = max($level, $g['level']);
}
}
return $level;
}
/** Rechtegruppen, die der Akteur vergeben darf (≤ eigene Ebene). */
public function assignableGroups(): array
{
$level = $this->actorLevel();
return array_keys(array_filter(self::GROUPS, fn ($g) => $g['level'] <= $level));
}
/**
* Prüft, ob der Akteur diese Rechtegruppe für den Ziel-Mandanten vergeben darf.
* Wirft AccessDenied bei Verstoß (Schutz vor Privilege-Escalation).
*/
public function assertCanAssign(string $group, ?Reseller $targetReseller, ?Company $targetCompany): void
{
if (!$this->isValidGroup($group)) {
throw new AccessDeniedHttpException('Unbekannte Rechtegruppe.');
}
$actorLevel = $this->actorLevel();
$targetLevel = self::GROUPS[$group]['level'];
// Regel 1: nie über die eigene Ebene hinaus
if ($targetLevel > $actorLevel) {
throw new AccessDeniedHttpException('Keine Berechtigung, diese Rechtegruppe zu vergeben.');
}
if ($actorLevel < self::MANAGE_LEVEL) {
throw new AccessDeniedHttpException('Keine Berechtigung zur Benutzerverwaltung.');
}
// Plattform-Admin: keine Mandantengrenze
if ($actorLevel >= 4) {
return;
}
/** @var Employee $actor */
$actor = $this->security->getUser();
// Regel 2: nur im eigenen Mandanten-Teilbaum
if ($actorLevel === 3) { // Reseller-Admin
$actorReseller = $actor->getReseller();
$effectiveReseller = $targetCompany?->getReseller() ?? $targetReseller;
if (null === $actorReseller || null === $effectiveReseller
|| !$effectiveReseller->getId()->equals($actorReseller->getId())) {
throw new AccessDeniedHttpException('Außerhalb des eigenen Resellers.');
}
return;
}
if ($actorLevel === 2) { // Firmen-Admin
$actorCompany = $actor->getCompany();
if (null === $actorCompany || null === $targetCompany
|| !$targetCompany->getId()->equals($actorCompany->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
}
}
}

View File

@ -1,299 +0,0 @@
<?php
namespace App\Service;
use App\Entity\Employee;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Erzeugt Wallet-Pässe für die digitale Visitenkarte eines Mitarbeiters:
* - Google Wallet: signierter „Save to Google Wallet"-JWT-Link (RS256).
* - Apple Wallet: signierte .pkpass (pass.json + Bilder + manifest + PKCS#7).
* Beides ist konfigurationsgesteuert (env); ohne Zugangsdaten deaktiviert.
*/
final class WalletService
{
public function __construct(
private readonly UrlGeneratorInterface $urls,
#[Autowire('%env(APPLE_WALLET_PASS_TYPE_ID)%')] private readonly string $applePassTypeId,
#[Autowire('%env(APPLE_WALLET_TEAM_ID)%')] private readonly string $appleTeamId,
#[Autowire('%env(APPLE_WALLET_ORG_NAME)%')] private readonly string $appleOrgName,
#[Autowire('%env(APPLE_WALLET_CERT_PATH)%')] private readonly string $appleCertPath,
#[Autowire('%env(APPLE_WALLET_KEY_PATH)%')] private readonly string $appleKeyPath,
#[Autowire('%env(APPLE_WALLET_KEY_PASSWORD)%')] private readonly string $appleKeyPassword,
#[Autowire('%env(APPLE_WALLET_WWDR_PATH)%')] private readonly string $appleWwdrPath,
#[Autowire('%env(GOOGLE_WALLET_ISSUER_ID)%')] private readonly string $googleIssuerId,
#[Autowire('%env(GOOGLE_WALLET_SERVICE_ACCOUNT)%')] private readonly string $googleServiceAccount,
#[Autowire('%env(GOOGLE_WALLET_CLASS_SUFFIX)%')] private readonly string $googleClassSuffix,
) {
}
public function isAppleConfigured(): bool
{
return '' !== $this->applePassTypeId && '' !== $this->appleTeamId
&& is_file($this->appleCertPath) && is_file($this->appleKeyPath) && is_file($this->appleWwdrPath);
}
public function isGoogleConfigured(): bool
{
return '' !== $this->googleIssuerId && '' !== $this->googleServiceAccount && is_file($this->googleServiceAccount);
}
// --- Google Wallet ---
public function googleSaveUrl(Employee $e): string
{
$sa = json_decode((string) file_get_contents($this->googleServiceAccount), true);
if (!is_array($sa) || !isset($sa['client_email'], $sa['private_key'])) {
throw new \RuntimeException('Google-Service-Account ungültig.');
}
$c = $this->card($e);
$classId = $this->googleIssuerId.'.'.$this->googleClassSuffix;
$objectId = $this->googleIssuerId.'.vcard_'.($e->getShortCode() ?? $e->getId()->toBase58());
$object = [
'id' => $objectId,
'classId' => $classId,
'state' => 'ACTIVE',
'hexBackgroundColor' => $c['primaryColor'],
'cardTitle' => $this->locValue($c['company']),
'header' => $this->locValue($c['name']),
'barcode' => ['type' => 'QR_CODE', 'value' => $c['url']],
'textModulesData' => array_values(array_filter([
'' !== $c['role'] ? ['id' => 'role', 'header' => 'Position', 'body' => $c['role']] : null,
'' !== $c['phone'] ? ['id' => 'phone', 'header' => 'Telefon', 'body' => $c['phone']] : null,
'' !== $c['email'] ? ['id' => 'email', 'header' => 'E-Mail', 'body' => $c['email']] : null,
])),
'linksModuleData' => ['uris' => [['uri' => $c['url'], 'description' => 'Profil öffnen', 'id' => 'profile']]],
];
if (null !== $c['logoUrl']) {
$object['logo'] = ['sourceUri' => ['uri' => $c['logoUrl']], 'contentDescription' => $this->locValue($c['company'])];
}
$claims = [
'iss' => $sa['client_email'],
'aud' => 'google',
'typ' => 'savetowallet',
'iat' => time(),
'payload' => [
'genericClasses' => [['id' => $classId]],
'genericObjects' => [$object],
],
];
return 'https://pay.google.com/gp/v/save/'.$this->signRs256($claims, (string) $sa['private_key']);
}
/** @param array<string, mixed> $claims */
private function signRs256(array $claims, string $privateKey): string
{
$segments = [
$this->b64url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])),
$this->b64url((string) json_encode($claims, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)),
];
$signature = '';
if (!openssl_sign(implode('.', $segments), $signature, $privateKey, \OPENSSL_ALGO_SHA256)) {
throw new \RuntimeException('JWT-Signatur fehlgeschlagen.');
}
$segments[] = $this->b64url($signature);
return implode('.', $segments);
}
// --- Apple Wallet (.pkpass) ---
public function applePkpass(Employee $e): string
{
$c = $this->card($e);
$pass = [
'formatVersion' => 1,
'passTypeIdentifier' => $this->applePassTypeId,
'serialNumber' => $e->getShortCode() ?? $e->getId()->toBase58(),
'teamIdentifier' => $this->appleTeamId,
'organizationName' => $this->appleOrgName,
'description' => 'Visitenkarte '.$c['name'],
'logoText' => $c['company'],
'foregroundColor' => 'rgb(255, 255, 255)',
'labelColor' => 'rgb(255, 255, 255)',
'backgroundColor' => $this->rgb($c['primaryColor']),
'barcodes' => [['format' => 'PKBARCODE_FORMAT_QR', 'message' => $c['url'], 'messageEncoding' => 'iso-8859-1']],
'generic' => [
'primaryFields' => [['key' => 'name', 'label' => '', 'value' => $c['name']]],
'secondaryFields' => array_values(array_filter([
'' !== $c['role'] ? ['key' => 'role', 'label' => 'POSITION', 'value' => $c['role']] : null,
['key' => 'company', 'label' => 'FIRMA', 'value' => $c['company']],
])),
'auxiliaryFields' => array_values(array_filter([
'' !== $c['phone'] ? ['key' => 'phone', 'label' => 'TELEFON', 'value' => $c['phone']] : null,
'' !== $c['email'] ? ['key' => 'email', 'label' => 'E-MAIL', 'value' => $c['email']] : null,
])),
'backFields' => [['key' => 'link', 'label' => 'Profil', 'value' => $c['url']]],
],
];
$files = ['pass.json' => (string) json_encode($pass, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)];
$files += $this->passImages($c);
$manifest = [];
foreach ($files as $name => $content) {
$manifest[$name] = sha1($content);
}
$files['manifest.json'] = (string) json_encode($manifest, \JSON_UNESCAPED_SLASHES);
$files['signature'] = $this->signManifest($files['manifest.json']);
return $this->zip($files);
}
/** Detached PKCS#7-Signatur der manifest.json im DER-Format. */
private function signManifest(string $manifest): string
{
$manifestFile = (string) tempnam(sys_get_temp_dir(), 'man');
$sigFile = (string) tempnam(sys_get_temp_dir(), 'sig');
file_put_contents($manifestFile, $manifest);
$ok = openssl_pkcs7_sign(
$manifestFile,
$sigFile,
'file://'.$this->appleCertPath,
['file://'.$this->appleKeyPath, $this->appleKeyPassword],
[],
\PKCS7_BINARY | \PKCS7_DETACHED,
$this->appleWwdrPath,
);
$smime = $ok ? (string) file_get_contents($sigFile) : '';
@unlink($manifestFile);
@unlink($sigFile);
if (!$ok) {
throw new \RuntimeException('Apple-Pass-Signatur fehlgeschlagen.');
}
return $this->smimeToDer($smime);
}
/** Extrahiert den DER-kodierten p7s-Block aus der S/MIME-Ausgabe von openssl. */
private function smimeToDer(string $smime): string
{
if (preg_match('/smime\.p7s.*?\r?\n\r?\n(.*?)\r?\n-{2,}/s', $smime, $m)) {
$b64 = $m[1];
} else {
$b64 = substr($smime, (int) strrpos($smime, "\n\n") + 2);
}
return (string) base64_decode((string) preg_replace('/\s+/', '', $b64));
}
/**
* @param array<string, string> $files
*/
private function zip(array $files): string
{
$tmp = (string) tempnam(sys_get_temp_dir(), 'pkpass');
$zip = new \ZipArchive();
$zip->open($tmp, \ZipArchive::OVERWRITE);
foreach ($files as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
$bin = (string) file_get_contents($tmp);
@unlink($tmp);
return $bin;
}
/**
* @param array{name: string, primaryColor: string} $c
*
* @return array<string, string>
*/
private function passImages(array $c): array
{
[$r, $g, $b] = $this->rgbParts($c['primaryColor']);
$initial = strtoupper(mb_substr($c['name'], 0, 1)) ?: 'V';
$make = function (int $size) use ($r, $g, $b, $initial): string {
$img = imagecreatetruecolor($size, $size);
imagefill($img, 0, 0, imagecolorallocate($img, $r, $g, $b));
$white = imagecolorallocate($img, 255, 255, 255);
$f = 5;
$tw = imagefontwidth($f) * strlen($initial);
$th = imagefontheight($f);
imagestring($img, $f, (int) (($size - $tw) / 2), (int) (($size - $th) / 2), $initial, $white);
ob_start();
imagepng($img);
$png = (string) ob_get_clean();
imagedestroy($img);
return $png;
};
return [
'icon.png' => $make(29),
'icon@2x.png' => $make(58),
'logo.png' => $make(50),
'logo@2x.png' => $make(100),
];
}
// --- gemeinsame Kartendaten ---
/**
* @return array{name: string, role: string, company: string, phone: string, email: string, url: string, primaryColor: string, logoUrl: ?string}
*/
private function card(Employee $e): array
{
$b = $e->getCompany()->getBrandingConfig();
$primary = (isset($b['primaryColor']) && is_string($b['primaryColor']) && preg_match('/^#[0-9a-fA-F]{6}$/', $b['primaryColor']))
? $b['primaryColor'] : '#f58220';
$logo = (isset($b['logoUrl']) && is_string($b['logoUrl']) && str_starts_with($b['logoUrl'], 'https://'))
? $b['logoUrl'] : null;
return [
'name' => trim($e->getFirstName().' '.$e->getLastName()),
'role' => trim((string) ($e->getPosition() ?? '')),
'company' => $e->getCompany()->getName(),
'phone' => (string) ($e->getPhone() ?? $e->getMobile() ?? ''),
'email' => (string) ($e->getEmail() ?? ''),
'url' => $this->shareUrl($e),
'primaryColor' => $primary,
'logoUrl' => $logo,
];
}
private function shareUrl(Employee $e): string
{
if (null !== $e->getShortCode()) {
return $this->urls->generate('short_link', ['code' => $e->getShortCode()], UrlGeneratorInterface::ABSOLUTE_URL);
}
return $this->urls->generate('public_profile', [
'companySlug' => $e->getCompany()->getSlug(),
'slug' => $e->getSlug(),
], UrlGeneratorInterface::ABSOLUTE_URL);
}
/** @return array{0: int, 1: int, 2: int} */
private function rgbParts(string $hex): array
{
$hex = ltrim($hex, '#');
return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))];
}
private function rgb(string $hex): string
{
[$r, $g, $b] = $this->rgbParts($hex);
return "rgb($r, $g, $b)";
}
/** @return array{defaultValue: array{language: string, value: string}} */
private function locValue(string $value): array
{
return ['defaultValue' => ['language' => 'de', 'value' => $value]];
}
private function b64url(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}

View File

@ -9,7 +9,6 @@ use App\Entity\ContactLink;
use App\Entity\Domain;
use App\Entity\Employee;
use App\Entity\Location;
use App\Entity\Product;
use App\Security\TenantContext;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
@ -57,8 +56,6 @@ final class TenantStampProcessor implements ProcessorInterface
match (true) {
$data instanceof Company => $data->setReseller($reseller),
// Reseller dürfen nur eigene Produkte anlegen/ändern (nie globale)
$data instanceof Product => $data->setReseller($reseller),
$data instanceof Location,
$data instanceof Domain => $this->assertCompany($data->getCompany()),
$data instanceof Employee => $this->assertEmployee($data),
@ -97,7 +94,6 @@ final class TenantStampProcessor implements ProcessorInterface
private function isTenantOwned(mixed $data): bool
{
return $data instanceof Company
|| $data instanceof Product
|| $data instanceof Location
|| $data instanceof Domain
|| $data instanceof Employee

View File

@ -144,16 +144,6 @@
<img src="{{ path('public_profile_qr', {companySlug: e.company.slug, slug: e.slug}) }}" alt="QR-Code zum Profil">
<p>QR-Code scannen, um dieses Profil zu öffnen</p>
</div>
{% if walletEnabled %}
<div class="vc__section qr">
<div class="vc__label">Zur Wallet hinzufügen</div>
<a href="{{ path('wallet_landing', {code: e.shortCode}) }}">
<img src="{{ path('wallet_qr', {code: e.shortCode}) }}" alt="QR-Code: Karte zu Apple/Google Wallet hinzufügen">
</a>
<p>Scannen, um die Karte in Apple&nbsp;/&nbsp;Google&nbsp;Wallet zu speichern</p>
</div>
{% endif %}
</div>
<div class="foot">

View File

@ -1,57 +0,0 @@
{% extends 'base.html.twig' %}
{% set fullName = (e.firstName ~ ' ' ~ e.lastName)|trim %}
{% set b = e.company.brandingConfig %}
{% set primary = (b.primaryColor is defined and b.primaryColor matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryColor : '#f58220' %}
{% block title %}{{ fullName }} zur Wallet hinzufügen{% endblock %}
{% block stylesheets %}
<style>
.wrap { max-width: 420px; margin: 0 auto; padding: 2rem 1.2rem; }
.wcard { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow-sm); padding: 2rem 1.6rem; text-align: center; }
.wcard .badge-name { display: inline-block; padding: .3rem .9rem; border-radius: 999px; background: {{ primary }}; color: #fff; font-weight: 700; font-size: .92rem; }
.wcard h1 { font-size: 1.25rem; margin: 1rem 0 .2rem; }
.wcard .muted { color: var(--muted); font-size: .9rem; margin: 0 0 1.4rem; }
.wbtns { display: flex; flex-direction: column; gap: .7rem; align-items: center; }
.wbtn { display: inline-flex; align-items: center; justify-content: center; gap: .6rem; width: 100%; max-width: 280px;
padding: .85rem 1.2rem; border-radius: 12px; font-weight: 700; font-size: .98rem; text-decoration: none; }
.wbtn--apple { background: #000; color: #fff; }
.wbtn--google { background: #fff; color: #3c4043; border: 1px solid #dadce0; }
.wbtn:hover { opacity: .92; text-decoration: none; }
.wbtn .ic { font-size: 1.1rem; }
.whint { color: var(--muted); font-size: .82rem; margin-top: 1.4rem; }
.wback { display: inline-block; margin-top: 1.2rem; color: var(--muted); font-size: .85rem; }
.order { order: 2; }
</style>
{% endblock %}
{% block body %}
<div class="wrap">
<div class="wcard">
<span class="badge-name">{{ e.company.name }}</span>
<h1>{{ fullName }}</h1>
<p class="muted">{% if e.position %}{{ e.position }} · {% endif %}Digitale Visitenkarte zur Wallet hinzufügen</p>
{% if appleEnabled or googleEnabled %}
<div class="wbtns">
{% if appleEnabled %}
<a class="wbtn wbtn--apple {{ preferApple ? '' : 'order' }}" href="{{ path('wallet_apple', {code: code}) }}">
<span class="ic"></span> Zu Apple Wallet
</a>
{% endif %}
{% if googleEnabled %}
<a class="wbtn wbtn--google {{ preferApple ? 'order' : '' }}" href="{{ path('wallet_google', {code: code}) }}">
<span class="ic">🅖</span> Zu Google Wallet
</a>
{% endif %}
</div>
<p class="whint">Öffne diesen Link auf deinem Smartphone, um die Karte zu speichern.</p>
{% else %}
<p class="whint">Wallet-Pässe sind für diesen Anbieter noch nicht aktiviert.</p>
{% endif %}
<a class="wback" href="{{ path('public_profile', {companySlug: e.company.slug, slug: e.slug}) }}">← zum Profil</a>
</div>
</div>
{% endblock %}

View File

@ -91,27 +91,3 @@ per CI oder Service-Discovery.)
Caddy stellt bereits Zertifikate für verifizierte Domains aus.
- **Seed**: optional einmalig `app:seed` auf `app-1` für Demo-Daten.
- **Updates**: neuen Stand ausrollen = auf den App-Nodes `git pull` + `docker compose ... up -d --build` (später per CI/Skript).
## Wallet-Pässe (Apple / Google) — optional
> **Ausführliche Schritt-für-Schritt-Anleitung: [`docs/WALLET-SETUP.md`](../docs/WALLET-SETUP.md).**
Auf der öffentlichen Profilseite erscheint ein QR „Zur Wallet hinzufügen" (Landing `/w/{code}`),
sobald die Zugangsdaten gesetzt sind. Ohne Konfiguration ist das Feature ausgeblendet.
**Apple Wallet** (kostenpflichtiger Apple-Developer-Account):
1. Pass Type ID anlegen, Zertifikat erzeugen → als PEM exportieren (`cert.pem` + `key.pem`).
2. Apple **WWDR**-Zwischenzertifikat als PEM (`wwdr.pem`).
3. PEM-Dateien außerhalb des Webroots ablegen, Env setzen:
`APPLE_WALLET_PASS_TYPE_ID`, `APPLE_WALLET_TEAM_ID`, `APPLE_WALLET_CERT_PATH`,
`APPLE_WALLET_KEY_PATH`, `APPLE_WALLET_KEY_PASSWORD`, `APPLE_WALLET_WWDR_PATH`, `APPLE_WALLET_ORG_NAME`.
**Google Wallet** (kostenlos):
1. In der Google Cloud Console die **Wallet API** + einen **Issuer** anlegen, Service-Account
mit Rolle „Wallet Object Issuer" erstellen, JSON-Key herunterladen.
2. Env setzen: `GOOGLE_WALLET_ISSUER_ID`, `GOOGLE_WALLET_SERVICE_ACCOUNT` (Pfad zur JSON),
optional `GOOGLE_WALLET_CLASS_SUFFIX`.
Hinweis: Selbstsignierte Test-Zertifikate erzeugen ein technisch valides `.pkpass`/JWT,
werden aber von Apple/Google **nicht akzeptiert** — für die Produktion echte Zugangsdaten nötig.
Over-the-air-Sync (APNs/Objekt-Patch) ist noch nicht umgesetzt (nur Pass-Erstellung).

View File

@ -32,44 +32,9 @@
**Hierarchie der Mandanten:** `Plattform → Reseller → Company → Location → Employee/Profil`
### Mehrere Logins pro Ebene
Jede Ebene kann **mehrere Benutzer** haben (mehrere Plattform-, Reseller-, Firmen-Admins).
Technisch: `User` ist die Login-Identität (1 Person = 1 Login), `n` User pro
Reseller bzw. Company. Ein `User` ist optional mit einem `Employee` (Profil) verknüpft —
so wird aus einem Mitarbeiter mit Profil per **Rechtegruppen-Zuweisung** zusätzlich
ein eingeloggter Admin.
### Rechtegruppen (zuweisbare Rollen)
Die Rollen werden im UI als wählbare **Rechtegruppe** angeboten — beim Anlegen/Bearbeiten
eines Benutzers bzw. direkt in der Mitarbeiterliste („kein Login" | „Mitarbeiter" |
„Firmen-Admin" | …). Start = die vier festen Rollen; später optional **granulare
Berechtigungs-Gruppen** (eigene Rechte-Sets pro Mandant).
### Delegierte Rechtevergabe (Kernregel)
Jede Ebene vergibt Rechte **nur an oder unterhalb der eigenen Ebene** und **nur im
eigenen Mandanten-Teilbaum** — niemals nach oben, nie über Mandantengrenzen hinweg:
| Akteur | darf Rechtegruppe vergeben | Geltungsbereich |
|--------|----------------------------|-----------------|
| Plattform-Admin | Plattform-Admin, Reseller-Admin (+ darunter) | alle Mandanten |
| Reseller-Admin | Reseller-Admin (weitere im eigenen Reseller), Firmen-Admin, Mitarbeiter | nur eigener Reseller + dessen Firmen |
| Firmen-Admin | Firmen-Admin (weitere der eigenen Firma), Mitarbeiter | nur eigene Firma |
| Mitarbeiter | — | — |
D. h.: *wir* vergeben Reseller-Rechte, Reseller vergeben Firmen-Admin-Rechte, Firmen-Admins
vergeben Mitarbeiter-/weitere Firmen-Admin-Rechte. Same-Level-Vergabe (weitere Admins der
eigenen Ebene) ist erlaubt → ermöglicht mehrere Logins pro Ebene.
Durchsetzung über:
- **Doctrine-Filter / API-Platform-Query-Extension** (automatisches Scoping nach
`reseller_id` / `company_id` je nach eingeloggtem Kontext)
- **Security Voters** (`CompanyVoter`, `EmployeeVoter`) für Aktion×Objekt
- **`RoleAssignmentVoter`/-Service**: prüft bei jeder Rollen-/Rechtegruppen-Vergabe,
dass die Zielrolle ≤ der höchsten Rolle des Akteurs ist **und** der Ziel-Benutzer im
Mandanten-Teilbaum des Akteurs liegt (Schutz vor Privilege-Escalation)
- **Doctrine-Filter** (automatisches Scoping nach `reseller_id` / `company_id` je nach eingeloggtem Kontext)
- **Security Voters** (z. B. `ProfileVoter`, `CompanyVoter`) für feingranulare Aktionen
- API-Platform `security`-Attribute pro Resource & Operation
---
@ -110,7 +75,7 @@ und legen sie versioniert ab. Öffentliche Endpunkte liefern immer den aktuellen
### Kernentitäten
- **User** — Auth-Identität (1 Login). Felder: `email`, `password`, `roles[]` (= Rechtegruppe), `status`, `lastLogin`. Verknüpft optional mit `reseller_id`, `company_id`, `employee_id`. **Mehrere User pro Reseller/Company** möglich; Rollenvergabe ist delegiert & scope-geprüft (siehe §2).
- **User** — Auth-Identität. Felder: `email`, `password`, `roles[]`, `status`, `lastLogin`. Verknüpft optional mit `reseller_id`, `company_id`, `employee_id` (je nach Rolle).
- **Reseller** — oberster Mandant. Felder: `name`, `slug`, `primaryDomain`, Branding-Defaults, `status`, `platformPlan_id`. Hat Limits aus dem Plattform-Paket.
- **PlatformPlan** — Reseller-Pakete (Starter/Professional/Business). Felder: `name`, `pricePerMonth`, `maxProfiles`, `maxCompanies`, `features[]`.
- **ResellerSubscription** — Abo des Resellers bei der Plattform. Felder: `plan_id`, `status`, `startedAt`, `renewsAt`, `paymentRef` (Stripe).
@ -337,115 +302,7 @@ Apple Pass Type ID ist an *einen* Apple-Account gebunden. Optionen: **(a)** ein
---
### Umsetzung (implementiert)
QR-Code auf der **öffentlichen Profilseite** („Zur Wallet hinzufügen") → Landing
`/w/{shortCode}` mit Geräteerkennung und Apple-/Google-Buttons. Beides
konfigurationsgesteuert (env); ohne Zugangsdaten ausgeblendet/deaktiviert.
- `WalletService` (dependency-frei): Google = signierter RS256-„Save"-JWT-Link
(`pay.google.com/gp/v/save/{jwt}`, fat genericClasses+Objects); Apple = `.pkpass`
(pass.json + GD-Icons + manifest + **PKCS#7-Signatur** via `openssl_pkcs7_sign`, gezippt).
- `WalletController`: `GET /w/{code}` (Landing), `/w/{code}/qr.png`,
`/w/{code}/apple.pkpass`, `/w/{code}/google` (302). Adressierung über `shortCode`.
- **Konfig (env):** Apple = `APPLE_WALLET_PASS_TYPE_ID`, `_TEAM_ID`, `_CERT_PATH`,
`_KEY_PATH`, `_KEY_PASSWORD`, `_WWDR_PATH`, `_ORG_NAME`; Google =
`GOOGLE_WALLET_ISSUER_ID`, `GOOGLE_WALLET_SERVICE_ACCOUNT` (Pfad zur
service-account.json), `GOOGLE_WALLET_CLASS_SUFFIX`.
- **Offen (Sync/Push):** Apple PassKit-Web-Service (`register`/`unregister`/`latest`)
+ APNs + `WalletDevice`; Google Objekt-`patch`. Bisher nur Pass-Erstellung.
---
## 13. Produktkatalog (mehrere Produkttypen)
Die Plattform verkauft nicht nur Visitenkarten, sondern mehrere **Produkttypen**.
Ein **Produkt** ist der Katalogeintrag, den eine Firma als druck-/ausrollbares
Erzeugnis bestellt und gestaltet. Start mit drei Typen: **Visitenkarte**,
**Namensschild**, **NFC-Karte**.
### Eigentümer & Sichtbarkeit
Produkte definieren **ausschließlich**:
| Definiert von | `reseller` | Sichtbar für |
|---------------|-----------|--------------|
| Plattform-Betreiber | `null` (global) | alle Reseller + deren Firmen |
| Reseller | eigener Reseller | nur eigener Mandant (Reseller + dessen Firmen) |
Firmen(-Admins) **wählen** aus den für sie sichtbaren Produkten (globale + die des
eigenen Resellers), legen aber **keine** Produkte an. Bearbeiten/Löschen darf jeweils
nur der Eigentümer (Plattform globale, Reseller die eigenen). Globale Produkte sind
für Reseller **read-only**.
### `kind` (Produktart) — legt Fähigkeiten & Defaults fest
| `kind` | Standardformat | Seiten | Druck | NFC |
|--------|----------------|--------|:----:|:---:|
| `business_card` (Visitenkarte) | 85 × 55 mm, 2 mm Bleed | V/R | ✓ | optional |
| `name_tag` (Namensschild) | 90 × 55 mm, 0 mm Bleed | nur V | ✓ | |
| `nfc_card` (NFC-Karte) | 85,6 × 54 mm (ID-1), 2 mm Bleed | V/R | ✓ | ✓ |
`kind` ist ein festes Enum (Fähigkeiten = Druck-PDF und/oder NFC-Programmierung).
Sowohl Plattform als auch Reseller legen Produkte **dieser** Arten an (z. B. ein
Reseller-eigenes „Premium-Visitenkarte 90×50").
### Datenmodell
**`Product`** (`ResellerOwnedInterface`): `reseller_id` (nullable = global), `kind`,
`name`, `description`, `widthMm`, `heightMm`, `bleedMm`, `safeMm`, `sides` (1|2),
`nfcEnabled`, `printEnabled`, `active`, `sortOrder`, `createdAt`.
**`CardTemplate`** referenziert künftig ein **`product_id`**: das konkrete **Design
einer Firma für ein Produkt** (eine Firma kann je Produkt ein Design haben). Format
(Maße/Bleed/Seiten) wird vom Produkt geerbt; der Renderer bleibt formatagnostisch.
### Endpunkte
- `GET /api/products` — sichtbare Produkte (global + eigener Reseller), API-Platform-scoped.
- `POST/PATCH/DELETE /api/products/{id}` — nur Eigentümer (Voter).
- Editor: `GET/PUT /api/companies/{id}/card-template?product={productId}` (Design je Produkt).
### Renderer/NFC je Produktart
- `business_card` / `nfc_card` / `name_tag` nutzen denselben `CardPdfRenderer`
(Maße + Elementliste aus Produkt + Design). Beschnitt/Schnittmarken nur wenn `bleedMm > 0`.
- `nfc_card`: zusätzlich NFC-Programmierung über die bestehende `shortCode`/`/t/`-Infra
(Tag schreibt die Kurz-URL des Profils) — Detail in §12/§14.
### Bestellungen (PrintOrder)
Firmen-Admins **bestellen** Produkte für ihre Mitarbeiter; der Reseller (Druckshop)
**wickelt ab** (produziert, versendet). Beispiel: *10 Visitenkarten + 5 NFC-Karten für
verschiedene Mitarbeiter*.
**Datenmodell:**
- **`PrintOrder`** (`ResellerOwnedInterface` via `company.reseller`): `number`
(kurze Bestellnr.), `company`, `status`, `note`, `createdBy` (Employee), `createdAt`,
`items[]`.
- **`OrderItem`**: `product`, `employee` (für wen — personalisiert), `quantity` (Auflage).
**Status-Workflow:** `new``in_production``shipped``completed`; `cancelled` quer.
- Firmen-Admin: legt Bestellung an (`new`), kann sie **stornieren** solange `new`.
- Reseller-/Plattform-Admin: schiebt den Status vorwärts (Produktion/Versand/erledigt),
kann jederzeit stornieren.
**Druckdaten:** je Position liefert das bestehende `GET /api/employees/{id}/card.pdf?product={productId}`
das druckfertige PDF (Mitarbeiter × Produkt). Sammel-/Bogen-PDF später.
**Endpunkte (`OrderController`, mandantengeprüft):**
- `GET /api/orders` — Liste (Firma: eigene; Reseller: alle seiner Firmen; Plattform: alle).
- `GET /api/orders/{id}` — Detail inkl. Positionen + PDF-Links.
- `POST /api/orders` — anlegen (Firmen-Admin; `items[]` = Produkt+Mitarbeiter+Menge).
- `PATCH /api/orders/{id}/status` — Status setzen (Reseller wickelt ab / Firma storniert).
**Sichtbarkeit:** Produkt muss im Mandanten sichtbar sein (global oder eigener Reseller),
Mitarbeiter muss zur bestellenden Firma gehören. Preise/Abrechnung = spätere Ausbaustufe.
---
## 14. Druckdaten: Visitenkarten-PDF (Kerngeschäft)
## 13. Druckdaten: Visitenkarten-PDF (Kerngeschäft)
Reseller drucken für ihre Firmenkunden Visitenkarten und brauchen **druckfertige PDFs**. Das Layout variiert **pro Firma**. Später kommt **Briefpapier** (gleiches System, anderes Format) dazu.
@ -534,74 +391,3 @@ Font-Embedding ist zugleich Voraussetzung für striktes **PDF/X**.
`CardTemplate.fonts` (`[{family, path}]`). Dateien liegen außerhalb des Webroots unter
`var/storage/cards/{companyId}/`. Upload-Endpunkte:
`POST /api/companies/{id}/card-template/background` (PDF) und `.../font` (TTF/OTF).
---
## 15. Zeiterfassung (Modul „Kommen/Gehen")
Erweiterung über die digitale Visitenkarte hinaus: eine **Arbeitszeiterfassung**
für die Firmenkunden der Reseller (vorzugsweise Druckshops). Der Mitarbeiter ist
die gemeinsame Klammer — dieselbe Identität (`Employee`), dieselben Standorte,
dieselbe NFC/QR-Infrastruktur. Positioniert die Plattform vom „Identity/Print-Tool"
hin zur **Mitarbeiter-Plattform** (ein Datensatz, mehrere Module).
### Markttreiber
EuGH 2019 + BAG 2022 → **Pflicht zur Arbeitszeiterfassung** in DE. SMB-Kunden
suchen einfache Lösungen → starkes Verkaufsargument; NFC-Terminals/Badges sind
zusätzlicher **Hardware-Umsatz** für den Druckshop.
### Erfassungswege
- **Web/App** (authentifizierter Mitarbeiter über sein `User`-Konto): Button
„Kommen / Gehen / Pause" im Dashboard bzw. Handy-Browser. Quelle `web`/`app`.
- **Kiosk** (geteiltes Standort-Gerät, per Geräte-Token): Mitarbeiter identifiziert
sich per **NFC-Tap** (`shortCode`) oder **PIN**. Quelle `kiosk`.
- NFC/QR nutzt die bestehende `shortCode`/`/t/`-Infrastruktur. *Hinweis:* da der
Profil-`shortCode` halb-öffentlich ist, am Kiosk zusätzlich **PIN** empfehlen.
### Datenmodell
**`TimeEntry`** — append-only Stempel-Ereignis (revisionssicher, **kein** Update/Delete):
`employee`, `type` (`clock_in`|`clock_out`|`break_start`|`break_end`),
`occurredAt` (Stempelzeit), `recordedAt` (Speicherzeit, immutable),
`source` (`web`|`app`|`kiosk`|`nfc`|`qr`|`manual`), `location` (nullable),
`createdBy` (User), `note`, `status` (`active`|`corrected`|`voided`),
`correctsEntry` (self-ref, nullable), `reason` (bei Korrektur/Storno).
**`KioskDevice`** — registriertes Standort-Terminal: `name`, `token`, `location`,
`status`, `lastSeenAt`.
**`Employee`-Ergänzung:** `clockPin` (optional, für Kiosk/App-Identifikation).
**Korrekturprinzip:** Originale bleiben erhalten; eine Korrektur erzeugt einen
**neuen** `TimeEntry` (`type=correction`-Bezug via `correctsEntry`) mit Grund +
Bearbeiter, der alte wird als `corrected` markiert. Der „gültige" Stundenzettel
wird aus den `active`-Einträgen berechnet. Jede Markierung ist protokolliert →
nachvollziehbar/manipulationssicher.
### Endpunkte (grob)
- `POST /api/time-clock/punch` — eingeloggter Mitarbeiter stempelt (Typ ermittelt
oder explizit), Quelle web/app.
- `POST /api/kiosk/punch` — Geräte-Token + Mitarbeiter-Kennung (NFC-`shortCode`/PIN).
- `GET /api/time-entries` — mandantengescoped (Mitarbeiter: eigene; Firmen-Admin: alle).
- `GET /api/timesheets?employee=&period=` — Aggregation (Tag/Woche, Überstunden, je Standort).
- `POST /api/time-entries/{id}/correct` — Firmen-Admin, hängt Korrektur an (Audit).
### Rollen
- **Mitarbeiter**: stempelt, sieht eigene Zeiten (Transparenzpflicht).
- **Firmen-Admin**: Stundenzettel, Korrekturen (mit Grund/Audit), Export, Kiosk-Geräte.
- **Reseller/Plattform**: white-label Einstellungen, Limits.
### Compliance (bewusst zu behandeln)
- **Revisionssicherheit**: append-only + protokollierte Korrekturen (s. o.).
- **ArbZG**: Pausen/Höchstarbeits-/Ruhezeiten erfassbar bzw. warnend.
- **DSGVO + BetrVG §87** (Mitbestimmung): kein heimliches Tracking, Einsicht für
Mitarbeiter, Standort EU, Lösch-/Exportkonzept.
### Auswertung & Export
Stundenzettel (Tag/Woche/Monat), Überstunden, Pausensummen je Mitarbeiter/Standort;
Export CSV/PDF — später Lohn-Schnittstelle (z. B. DATEV).
### MVP-Abgrenzung
**Drin:** Kommen/Gehen/Pause über Web/App/Kiosk (NFC/PIN), Stundenzettel,
revisionssichere Korrektur, CSV/PDF-Export. **Draußen (Folgeschritte):** Urlaub/
Krankmeldung, Schicht-/Dienstplanung, Projektzeiten, DATEV-Export, Geofencing.

View File

@ -1,112 +0,0 @@
# Wallet-Pässe einrichten (Apple & Google)
Schritt-für-Schritt, um die „Zur Wallet hinzufügen"-Funktion scharf zu schalten.
Ohne diese Zugangsdaten bleibt das Feature ausgeblendet. Ein Mac ist **nicht** nötig
alles geht per OpenSSL.
Die fertigen Dateien und Werte trägst du am Ende als **Environment-Variablen** ein:
- **Lokal (Docker):** Dateien nach `backend/var/wallet/` legen (ist gitignored), Werte in `backend/.env.local`. Pfade sind Container-Pfade `/app/var/wallet/...`.
- **Produktiv:** Dateien außerhalb des Webroots auf den Server, Werte in der Server-Env / `.env.prod.local`.
---
## A) Apple Wallet (PassKit)
Voraussetzung: kostenpflichtiger **Apple Developer**-Account.
### 1. Pass Type ID anlegen
1. https://developer.apple.com/account → **Certificates, Identifiers & Profiles****Identifiers****+**.
2. Typ **Pass Type IDs** wählen → Beschreibung + Identifier vergeben, z. B. `pass.de.vcard4reseller.card`.
3. Dieser Identifier ist später `APPLE_WALLET_PASS_TYPE_ID`.
### 2. Privatschlüssel + CSR erzeugen (per OpenSSL)
```bash
mkdir -p backend/var/wallet && cd backend/var/wallet
openssl genrsa -out key.pem 2048
openssl req -new -key key.pem -out request.csr \
-subj "/emailAddress=DEINE@MAIL.de/CN=vcard4reseller Pass/O=vcard4reseller/C=DE"
```
`key.pem` ist dein **unverschlüsselter** Privatschlüssel (→ `APPLE_WALLET_KEY_PASSWORD` bleibt leer).
### 3. Zertifikat erstellen & als PEM speichern
1. Im Apple-Portal beim Pass Type ID → **Create Certificate** → die eben erzeugte `request.csr` hochladen.
2. Das resultierende `pass.cer` (DER) herunterladen → nach `backend/var/wallet/` legen → umwandeln:
```bash
openssl x509 -inform DER -in pass.cer -out cert.pem
```
`cert.pem` = `APPLE_WALLET_CERT_PATH`.
### 4. Apple-WWDR-Zwischenzertifikat
1. https://www.apple.com/certificateauthority/ → **Worldwide Developer Relations** Intermediate Certificate (aktuell **G4**) herunterladen (`AppleWWDRCAG4.cer`).
2. Umwandeln:
```bash
openssl x509 -inform DER -in AppleWWDRCAG4.cer -out wwdr.pem
```
`wwdr.pem` = `APPLE_WALLET_WWDR_PATH`.
### 5. Team ID
https://developer.apple.com/account → **Membership****Team ID** (10 Zeichen) = `APPLE_WALLET_TEAM_ID`.
### Ergebnis (Env)
```dotenv
APPLE_WALLET_PASS_TYPE_ID=pass.de.vcard4reseller.card
APPLE_WALLET_TEAM_ID=ABCDE12345
APPLE_WALLET_ORG_NAME=vcard4reseller
APPLE_WALLET_CERT_PATH=/app/var/wallet/cert.pem
APPLE_WALLET_KEY_PATH=/app/var/wallet/key.pem
APPLE_WALLET_KEY_PASSWORD=
APPLE_WALLET_WWDR_PATH=/app/var/wallet/wwdr.pem
```
> Falls du das Zertifikat als `.p12` aus dem Keychain exportierst statt per CSR:
> `openssl pkcs12 -legacy -in Cert.p12 -clcerts -nokeys -out cert.pem` und
> `openssl pkcs12 -legacy -in Cert.p12 -nocerts -nodes -out key.pem`.
---
## B) Google Wallet
Voraussetzung: Google-Konto + Google Cloud (kostenlos).
### 1. Wallet API aktivieren
https://console.cloud.google.com → Projekt wählen/anlegen → **APIs & Dienste****Google Wallet API** suchen → **Aktivieren**.
### 2. Issuer-Account anlegen
1. https://pay.google.com/business/console → **Google Wallet API** → Issuer-Profil anlegen.
2. Die **Issuer ID** (Zahl) = `GOOGLE_WALLET_ISSUER_ID`.
### 3. Service-Account + JSON-Key
1. Cloud Console → **IAM & Verwaltung****Dienstkonten****Dienstkonto erstellen**.
2. Anlegen, dann **Schlüssel****Neuen Schlüssel****JSON** → herunterladen.
3. JSON nach `backend/var/wallet/google-sa.json` legen → `GOOGLE_WALLET_SERVICE_ACCOUNT`.
### 4. Service-Account dem Issuer freigeben
Im **Google Pay & Wallet Console** → Issuer → **Users / Nutzer** → die **E-Mail des Dienstkontos**
(`...iam.gserviceaccount.com`) mit Rolle **Developer** hinzufügen. Sonst → 403 beim Speichern.
### Ergebnis (Env)
```dotenv
GOOGLE_WALLET_ISSUER_ID=3388000000000000000
GOOGLE_WALLET_SERVICE_ACCOUNT=/app/var/wallet/google-sa.json
GOOGLE_WALLET_CLASS_SUFFIX=vcard_generic
```
> **Demo-Modus:** Solange der Issuer noch nicht von Google freigegeben ist, können nur
> **Test-Nutzer** (im Pay & Wallet Console unter dem Issuer hinzugefügt) Pässe speichern.
> Für die Veröffentlichung muss der Issuer den Google-Freigabeprozess durchlaufen.
> Logos erscheinen nur, wenn das Firmen-Branding eine **öffentliche https-Logo-URL** hat.
---
## Aktivieren & testen
1. Werte in `backend/.env.local` (lokal) bzw. Server-Env eintragen.
2. Cache leeren: `docker compose exec php php bin/console cache:clear`.
3. Ein Mitarbeiter mit `shortCode` → öffentliche Profilseite zeigt jetzt den QR **„Zur Wallet hinzufügen"**.
Direktlinks: `/w/{shortCode}` (Landing), `/w/{shortCode}/apple.pkpass`, `/w/{shortCode}/google`.
4. Auf dem Smartphone scannen/öffnen → „Zu Apple/Google Wallet".
**Hinweis:** Self-signed Test-Zertifikate erzeugen technisch valide Pässe, werden aber von
Apple/Google **nicht akzeptiert** für echte Pässe sind die obigen echten Zugangsdaten nötig.
Der automatische Over-the-air-Sync (Apple APNs / Google Objekt-Patch bei Datenänderung) ist
noch nicht umgesetzt; bisher wird der Pass beim Hinzufügen erzeugt.

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
defineProps<{ title: string; wide?: boolean }>()
defineProps<{ title: string }>()
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div class="overlay" @click.self="emit('close')">
<div class="card modal" :class="{ 'modal--wide': wide }">
<div class="card modal">
<header class="modal__head">
<h3>{{ title }}</h3>
<button class="x" @click="emit('close')" aria-label="Schließen">×</button>
@ -23,7 +23,6 @@ const emit = defineEmits<{ close: [] }>()
display: flex; align-items: center; justify-content: center; padding: 1rem; z-index: 50;
}
.modal { width: 100%; max-width: 480px; max-height: 90vh; overflow: auto; }
.modal--wide { max-width: 680px; }
.modal__head {
display: flex; align-items: center; justify-content: space-between;
padding: 1.1rem 1.4rem; border-bottom: 1px solid var(--line);

View File

@ -13,12 +13,10 @@ const nav = computed<NavItem[]>(() => [
{ label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin },
{ label: 'Firmen', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Mitarbeiter', to: '/app/employees', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', show: true },
{ label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Editor', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: auth.isResellerAdmin || auth.isCompanyAdmin },
{ label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin },
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin },
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isCompanyAdmin },
{ label: 'Visitenkarten', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: true },
{ label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin || auth.isResellerAdmin },
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin || auth.isResellerAdmin },
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: true },
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: true },
].filter((i) => i.show))
@ -30,11 +28,6 @@ function logout() {
auth.logout()
router.push('/login')
}
async function stopImpersonation() {
await auth.stopImpersonation()
router.push('/app')
}
</script>
<template>
@ -54,10 +47,6 @@ async function stopImpersonation() {
</aside>
<div class="main">
<div v-if="auth.isImpersonating" class="imp-banner">
<span>Du arbeitest als <strong>{{ auth.actingAs?.name }}</strong> ({{ auth.actingAs?.email }})</span>
<button class="btn btn-sm" @click="stopImpersonation">Beenden</button>
</div>
<header class="topbar">
<div class="topbar__ctx">
<span class="muted">Mandant</span>
@ -92,8 +81,6 @@ async function stopImpersonation() {
.navlink.active { background: var(--psc-orange); color: #fff; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.imp-banner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .55rem 1.6rem; background: var(--psc-orange); color: #fff; font-size: .9rem; font-weight: 600; }
.imp-banner .btn { background: #fff; color: var(--psc-orange-dark); }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.6rem; background: #fff; border-bottom: 1px solid var(--line);

View File

@ -19,9 +19,7 @@ const router = createRouter({
{ path: 'resellers', name: 'resellers', component: () => import('@/views/ResellersView.vue') },
{ path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') },
{ path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') },
{ path: 'products', name: 'products', component: () => import('@/views/ProductsView.vue') },
{ path: 'card-editor', name: 'card-editor', component: () => import('@/views/CardEditorView.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/views/OrdersView.vue') },
{ path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
{ path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') },

View File

@ -15,29 +15,17 @@ export interface CurrentUser {
company: TenantRef | null
}
const ROLE_LEVEL: Record<string, number> = {
ROLE_PLATFORM_ADMIN: 4,
ROLE_RESELLER_ADMIN: 3,
ROLE_COMPANY_ADMIN: 2,
ROLE_EMPLOYEE: 1,
ROLE_CONTACT: 0,
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const user = ref<CurrentUser | null>(JSON.parse(localStorage.getItem('user') || 'null'))
const actingAs = ref<{ name: string; email: string } | null>(
JSON.parse(localStorage.getItem('actingAs') || 'null'),
const user = ref<CurrentUser | null>(
JSON.parse(localStorage.getItem('user') || 'null'),
)
const originalToken = ref<string | null>(localStorage.getItem('token_original'))
const isAuthenticated = computed(() => !!token.value)
const roles = computed(() => user.value?.roles ?? [])
const isPlatformAdmin = computed(() => roles.value.includes('ROLE_PLATFORM_ADMIN'))
const isResellerAdmin = computed(() => roles.value.includes('ROLE_RESELLER_ADMIN'))
const isCompanyAdmin = computed(() => roles.value.includes('ROLE_COMPANY_ADMIN'))
const level = computed(() => Math.max(0, ...roles.value.map((r) => ROLE_LEVEL[r] ?? -1)))
const isImpersonating = computed(() => !!originalToken.value)
async function login(email: string, password: string) {
const { data } = await client.post('/login', { email, password })
@ -52,45 +40,16 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem('user', JSON.stringify(data))
}
// „Arbeiten als" tauscht das Token gegen ein impersoniertes (absteigend, backend-geprüft)
async function impersonate(employeeId: string) {
const { data } = await client.post(`/impersonate/${employeeId}`)
if (!originalToken.value) {
originalToken.value = token.value ?? ''
localStorage.setItem('token_original', originalToken.value)
}
token.value = data.token
localStorage.setItem('token', data.token)
actingAs.value = data.actingAs
localStorage.setItem('actingAs', JSON.stringify(data.actingAs))
await fetchMe()
}
async function stopImpersonation() {
if (!originalToken.value) return
token.value = originalToken.value
localStorage.setItem('token', originalToken.value)
originalToken.value = null
localStorage.removeItem('token_original')
localStorage.removeItem('actingAs')
actingAs.value = null
await fetchMe()
}
function logout() {
token.value = null
user.value = null
actingAs.value = null
originalToken.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('token_original')
localStorage.removeItem('actingAs')
}
return {
token, user, actingAs, isAuthenticated, roles, level,
isPlatformAdmin, isResellerAdmin, isCompanyAdmin, isImpersonating,
login, fetchMe, impersonate, stopImpersonation, logout,
token, user, isAuthenticated, roles,
isPlatformAdmin, isResellerAdmin, isCompanyAdmin,
login, fetchMe, logout,
}
})

View File

@ -14,7 +14,6 @@ interface Template {
front: El[]; back: El[]
hasBackground?: boolean; fonts?: string[]
}
interface Product { '@id': string; id: string; kind: string; name: string; sides: number; nfcEnabled: boolean; active: boolean }
interface Company { '@id': string; id: string; name: string; slug: string; brandingConfig: Record<string, string> | unknown[] }
interface Employee { id: string; firstName: string; lastName: string; position: string | null; email: string | null; phone: string | null; mobile: string | null; company: string; department: string | null }
@ -22,8 +21,6 @@ const SCALE = 6 // px pro mm
const companies = ref<Company[]>([])
const selectedCompanyId = ref('')
const products = ref<Product[]>([])
const selectedProductId = ref('')
const tpl = ref<Template | null>(null)
const side = ref<'front' | 'back'>('front')
const selectedIndex = ref<number | null>(null)
@ -120,14 +117,11 @@ function elStyle(el: El): any {
}
}
const selectedProduct = computed(() => products.value.find((p) => p['@id'] === selectedProductId.value))
function qp() { return { params: { product: selectedProduct.value?.id } } }
// --- Laden ---
async function loadTemplate() {
if (!selectedCompanyId.value || !selectedProductId.value) return
if (!selectedCompanyId.value) return
const cid = selectedCompany.value!.id
const { data } = await client.get<Template>(`/companies/${cid}/card-template`, qp())
const { data } = await client.get<Template>(`/companies/${cid}/card-template`)
tpl.value = data
selectedIndex.value = null
history.value = []
@ -140,7 +134,7 @@ async function renderBackground() {
bgImages.value = { front: null, back: null }
if (!tpl.value?.hasBackground) return
try {
const res = await client.get(`/companies/${selectedCompany.value!.id}/card-template/background`, { responseType: 'arraybuffer', params: { product: selectedProduct.value?.id } })
const res = await client.get(`/companies/${selectedCompany.value!.id}/card-template/background`, { responseType: 'arraybuffer' })
const doc = await pdfjsLib.getDocument({ data: new Uint8Array(res.data as ArrayBuffer) }).promise
const pxW = (tpl.value.widthMm + 2 * tpl.value.bleedMm) * SCALE
for (let p = 1; p <= Math.min(2, doc.numPages); p++) {
@ -161,13 +155,11 @@ async function renderBackground() {
}
async function load() {
loading.value = true
;[companies.value, employees.value, products.value] = await Promise.all([
;[companies.value, employees.value] = await Promise.all([
list<Company>('companies').then((r) => r.member),
list<Employee>('employees').then((r) => r.member).catch(() => []),
list<Product>('products').then((r) => r.member.filter((p) => p.active)).catch(() => []),
])
if (companies.value[0]) selectedCompanyId.value = companies.value[0]['@id']
if (products.value[0]) selectedProductId.value = products.value[0]['@id']
await loadTemplate()
loading.value = false
}
@ -245,7 +237,7 @@ async function save() {
if (!tpl.value || !selectedCompany.value) return
saving.value = true; saved.value = false
try {
const { data } = await client.put<Template>(`/companies/${selectedCompany.value.id}/card-template`, tpl.value, qp())
const { data } = await client.put<Template>(`/companies/${selectedCompany.value.id}/card-template`, tpl.value)
tpl.value = data
saved.value = true
} finally { saving.value = false }
@ -260,14 +252,14 @@ async function uploadBackground(e: Event) {
uploading.value = true
try {
const fd = new FormData(); fd.append('file', file)
await client.post(`/companies/${cidPath()}/card-template/background`, fd, qp())
await client.post(`/companies/${cidPath()}/card-template/background`, fd)
tpl.value.hasBackground = true
await renderBackground()
} catch { alert('Upload fehlgeschlagen (nur PDF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
}
async function removeBackground() {
if (!tpl.value || !confirm('Hintergrund-PDF entfernen?')) return
await client.delete(`/companies/${cidPath()}/card-template/background`, qp())
await client.delete(`/companies/${cidPath()}/card-template/background`)
tpl.value.hasBackground = false
bgImages.value = { front: null, back: null }
}
@ -279,14 +271,14 @@ async function uploadFont(e: Event) {
uploading.value = true
try {
const fd = new FormData(); fd.append('file', file); fd.append('family', family)
await client.post(`/companies/${cidPath()}/card-template/font`, fd, qp())
await client.post(`/companies/${cidPath()}/card-template/font`, fd)
tpl.value.fonts = [...(tpl.value.fonts ?? []).filter((f) => f !== family), family]
} catch { alert('Upload fehlgeschlagen (nur TTF/OTF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
}
async function previewPdf() {
if (!sample.value) { alert('Kein Mitarbeiter in dieser Firma für die Vorschau.'); return }
const res = await client.get(`/employees/${sample.value.id}/card.pdf`, { responseType: 'blob', params: { product: selectedProduct.value?.id } })
const res = await client.get(`/employees/${sample.value.id}/card.pdf`, { responseType: 'blob' })
window.open(URL.createObjectURL(res.data as Blob), '_blank')
}
@ -300,14 +292,11 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<template>
<section>
<div class="page-head">
<div><h1>Produkt-Editor</h1><p class="muted">Design je Firma &amp; Produkt · druckfertiges PDF</p></div>
<div><h1>Visitenkarten-Editor</h1><p class="muted">Layout pro Firma · druckfertiges PDF</p></div>
<div class="head-actions">
<select v-if="companies.length > 1" class="input" v-model="selectedCompanyId" @change="loadTemplate">
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
</select>
<select class="input" v-model="selectedProductId" @change="loadTemplate" :disabled="!products.length">
<option v-for="p in products" :key="p['@id']" :value="p['@id']">{{ p.name }}</option>
</select>
<button class="btn btn-ghost" :disabled="!history.length" @click="undo" title="Rückgängig (Strg+Z)"></button>
<button class="btn btn-ghost" @click="previewPdf">PDF-Vorschau</button>
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? 'Speichern' : 'Speichern' }}</button>
@ -391,16 +380,16 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<!-- Karten-/Format-Einstellungen, wenn kein Element ausgewählt ist -->
<template v-if="!selected">
<div class="field"><label>Name des Designs</label><input class="input" v-model="tpl.name" /></div>
<div class="field"><label>Name der Vorlage</label><input class="input" v-model="tpl.name" /></div>
<div class="grid2">
<div class="field"><label>Breite (mm)</label><input class="input" type="number" :value="tpl.widthMm" disabled /></div>
<div class="field"><label>Höhe (mm)</label><input class="input" :value="tpl.heightMm" disabled /></div>
<div class="field"><label>Breite (mm)</label><input class="input" type="number" step="1" v-model.number="tpl.widthMm" /></div>
<div class="field"><label>Höhe (mm)</label><input class="input" type="number" step="1" v-model.number="tpl.heightMm" /></div>
</div>
<div class="grid2">
<div class="field"><label>Beschnitt (mm)</label><input class="input" :value="tpl.bleedMm" disabled /></div>
<div class="field"><label>Sicherheit (mm)</label><input class="input" :value="tpl.safeMm" disabled /></div>
<div class="field"><label>Beschnitt (mm)</label><input class="input" type="number" step="0.5" min="0" v-model.number="tpl.bleedMm" /></div>
<div class="field"><label>Sicherheit (mm)</label><input class="input" type="number" step="0.5" min="0" v-model.number="tpl.safeMm" /></div>
</div>
<p class="muted small">Format wird vom Produkt <strong>{{ selectedProduct?.name }}</strong> vorgegeben. Element anklicken, um es zu bearbeiten.</p>
<p class="muted small">Standard: 85×55&nbsp;mm, 2&nbsp;mm Beschnitt. Wird beim Speichern übernommen und gilt fürs Druck-PDF. Element anklicken, um es zu bearbeiten.</p>
</template>
<template v-if="selected">

View File

@ -1,23 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { list, create, update, remove } from '@/api/resources'
import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
const GROUP_LABEL: Record<string, string> = {
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter', contact: 'Kontakt',
}
const ROLE_GROUP: [string, string][] = [
['ROLE_PLATFORM_ADMIN', 'platform_admin'], ['ROLE_RESELLER_ADMIN', 'reseller_admin'],
['ROLE_COMPANY_ADMIN', 'company_admin'], ['ROLE_EMPLOYEE', 'employee'], ['ROLE_CONTACT', 'contact'],
]
const GROUP_LEVEL: Record<string, number> = {
platform_admin: 4, reseller_admin: 3, company_admin: 2, employee: 1, contact: 0,
}
interface Employee {
'@id': string
id: string
@ -31,8 +17,6 @@ interface Employee {
mobile: string | null
status: string
shortCode: string | null
roles: string[]
login: boolean
company: string
location: string | null
}
@ -40,7 +24,6 @@ interface Company { '@id': string; name: string; slug: string }
interface Location { '@id': string; name: string; company: string }
const auth = useAuthStore()
const router = useRouter()
const PUBLIC_BASE = import.meta.env.VITE_PUBLIC_BASE ?? 'http://localhost:8080'
const employees = ref<Employee[]>([])
@ -49,23 +32,6 @@ const locations = ref<Location[]>([])
const loading = ref(true)
const search = ref('')
const canManageUsers = computed(() => auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin)
const assignableGroups = ref<string[]>([])
const accessForm = ref({ group: 'contact', password: '' })
function groupOf(e: Employee): string {
const roles = e.roles ?? []
for (const [role, group] of ROLE_GROUP) if (roles.includes(role)) return group
return 'contact'
}
function canWorkAs(e: Employee): boolean {
return e.login && GROUP_LEVEL[groupOf(e)] < auth.level && e.id !== auth.user?.id
}
async function workAs(e: Employee) {
await auth.impersonate(e.id)
router.push('/app')
}
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c])))
const filtered = computed(() => {
const q = search.value.toLowerCase().trim()
@ -92,33 +58,9 @@ async function load() {
list<Location>('locations').then((r) => r.member).catch(() => []),
list<Employee>('employees').then((r) => r.member),
])
if (canManageUsers.value) {
try {
assignableGroups.value = (await client.get('/users/assignable-groups')).data.groups
} catch { /* darf Gruppen nicht abrufen egal */ }
}
loading.value = false
}
async function saveAccess(e: Employee) {
try {
const payload: Record<string, unknown> = { group: accessForm.value.group }
if (accessForm.value.password) payload.password = accessForm.value.password
await client.patch(`/employees/${e.id}/access`, payload)
accessForm.value.password = ''
await load()
editing.value = employees.value.find((x) => x.id === e.id) ?? null
} catch (err: any) {
alert(err?.response?.data?.error ?? err?.response?.data?.detail ?? 'Speichern fehlgeschlagen.')
}
}
async function removeLogin(e: Employee) {
if (!confirm('Login entziehen? Der Mitarbeiter wird wieder reiner Kontakt.')) return
await client.delete(`/employees/${e.id}/login`)
await load()
editing.value = employees.value.find((x) => x.id === e.id) ?? null
}
// --- Anlegen / Bearbeiten ---
const showForm = ref(false)
const saving = ref(false)
@ -153,7 +95,6 @@ function openEdit(e: Employee) {
email: e.email ?? '', phone: e.phone ?? '', mobile: e.mobile ?? '',
company: e.company, location: e.location ?? '',
}
accessForm.value = { group: groupOf(e), password: '' }
error.value = ''
showForm.value = true
}
@ -218,7 +159,7 @@ onMounted(load)
</div>
<table class="tbl">
<thead>
<tr><th>Name</th><th>Position</th><th>Rechtegruppe</th><th>Standort</th><th></th></tr>
<tr><th>Name</th><th>Position</th><th>Standort</th><th>Status</th><th></th></tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="5" class="empty">Lädt</td></tr>
@ -234,13 +175,9 @@ onMounted(load)
</div>
</td>
<td>{{ e.position ?? '' }}</td>
<td>
<span class="badge badge-role">{{ GROUP_LABEL[groupOf(e)] }}</span>
<span v-if="e.login" class="muted small" title="Login aktiv"> · 🔑</span>
</td>
<td class="muted">{{ locName(e.location) }}</td>
<td><span class="badge" :class="e.status === 'active' ? 'badge-active' : 'badge-inactive'">{{ e.status }}</span></td>
<td class="right">
<button v-if="canWorkAs(e)" class="btn btn-soft btn-sm" @click="workAs(e)">Arbeiten als</button>
<a class="btn btn-soft btn-sm" :href="profileUrl(e)" target="_blank" rel="noopener">Profil </a>
<button class="btn btn-ghost btn-sm" @click="openEdit(e)">Bearbeiten</button>
<button class="btn btn-ghost btn-sm" @click="del(e)">Löschen</button>
@ -288,28 +225,6 @@ onMounted(load)
<button type="button" class="btn btn-ghost btn-sm" @click="copyShort(editing)">Kopieren</button>
</div>
</div>
<div v-if="editing && canManageUsers" class="login-box">
<div class="nfc__label">Rechtegruppe & Login</div>
<div class="grid2">
<div class="field">
<label>Rechtegruppe</label>
<select class="input" v-model="accessForm.group">
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
</select>
</div>
<div class="field">
<label>{{ editing.login ? 'Neues Passwort (optional)' : 'Passwort (für Login)' }}</label>
<input class="input" type="password" v-model="accessForm.password" minlength="6" placeholder="leer = kein Login" />
</div>
</div>
<div class="access-actions">
<button type="button" class="btn btn-soft btn-sm" @click="saveAccess(editing)">Rechtegruppe übernehmen</button>
<button v-if="editing.login" type="button" class="btn btn-ghost btn-sm" @click="removeLogin(editing)">Login entziehen</button>
<span class="muted small">Aktuell: {{ GROUP_LABEL[groupOf(editing)] }} · Login {{ editing.login ? 'aktiv' : 'inaktiv' }}</span>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
@ -342,8 +257,4 @@ onMounted(load)
.nfc__label { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin-bottom: .4rem; }
.nfc__row { display: flex; align-items: center; justify-content: space-between; gap: .6rem; }
.nfc__row code { font-size: .82rem; word-break: break-all; }
.login-box { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .6rem; }
.login-box .grid2 { margin-bottom: .4rem; }
.access-actions { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
</style>

View File

@ -1,331 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import client from '@/api/client'
import { list } from '@/api/resources'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
interface OrderSummary {
id: string; number: string; status: string
company: { id: string; name: string }
itemCount: number; totalQuantity: number; createdAt: string
}
interface OrderItem {
id: string; quantity: number
product: { id: string; name: string; kind: string }
employee: { id: string; name: string }
pdfUrl: string
}
interface OrderDetail extends OrderSummary {
note: string | null; createdBy: string | null; items: OrderItem[]
}
interface Product { '@id': string; id: string; name: string; kind: string; active: boolean }
interface Employee { '@id': string; id: string; firstName: string; lastName: string }
const STATUS_LABEL: Record<string, string> = {
new: 'Neu', in_production: 'In Produktion', shipped: 'Versandt', completed: 'Erledigt', cancelled: 'Storniert',
}
const STATUS_CLASS: Record<string, string> = {
new: 's-new', in_production: 's-prod', shipped: 's-ship', completed: 's-done', cancelled: 's-cancel',
}
const NEXT: Record<string, string> = { new: 'in_production', in_production: 'shipped', shipped: 'completed' }
const auth = useAuthStore()
const canOrder = computed(() => auth.isCompanyAdmin)
const canFulfill = computed(() => auth.isResellerAdmin || auth.isPlatformAdmin)
const showCompanyCol = computed(() => canFulfill.value)
const orders = ref<OrderSummary[]>([])
const loading = ref(true)
const products = ref<Product[]>([])
const employees = ref<Employee[]>([])
const showCreate = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref<{ note: string; items: { product: string; employee: string; quantity: number }[] }>({
note: '', items: [{ product: '', employee: '', quantity: 100 }],
})
const detail = ref<OrderDetail | null>(null)
const busy = ref(false)
// Vorschau der gerenderten Karte (Mitarbeiter × Produkt) vor dem Bestellen
const preview = ref<{ employee: string; product: string; loading: boolean; pages: string[] } | null>(null)
async function load() {
loading.value = true
const { data } = await client.get<{ member: OrderSummary[] }>('/orders')
orders.value = data.member ?? []
loading.value = false
}
async function loadRefs() {
;[products.value, employees.value] = await Promise.all([
list<Product>('products').then((r) => r.member.filter((p) => p.active)).catch(() => []),
list<Employee>('employees').then((r) => r.member).catch(() => []),
])
}
function openCreate() {
form.value = { note: '', items: [{ product: products.value[0]?.['@id'] ?? '', employee: '', quantity: 100 }] }
error.value = ''
showCreate.value = true
}
function addRow() {
form.value.items.push({ product: products.value[0]?.['@id'] ?? '', employee: '', quantity: 100 })
}
function removeRow(i: number) {
form.value.items.splice(i, 1)
}
function idOf(iri: string) { return iri.split('/').pop() as string }
async function submit() {
error.value = ''
const items = form.value.items
.filter((r) => r.product && r.employee && r.quantity > 0)
.map((r) => ({ product: idOf(r.product), employee: idOf(r.employee), quantity: Number(r.quantity) }))
if (!items.length) { error.value = 'Mindestens eine vollständige Position angeben.'; return }
saving.value = true
try {
await client.post('/orders', { note: form.value.note || null, items })
showCreate.value = false
await load()
} catch {
error.value = 'Bestellung konnte nicht angelegt werden.'
} finally {
saving.value = false
}
}
async function openDetail(o: OrderSummary) {
const { data } = await client.get<OrderDetail>(`/orders/${o.id}`)
detail.value = data
}
async function setStatus(status: string) {
if (!detail.value) return
busy.value = true
try {
const { data } = await client.patch<OrderDetail>(`/orders/${detail.value.id}/status`, { status })
detail.value = data
await load()
} finally { busy.value = false }
}
async function openPdf(item: OrderItem) {
const res = await client.get(item.pdfUrl.replace(/^\/api/, ''), { responseType: 'blob' })
window.open(URL.createObjectURL(res.data as Blob), '_blank')
}
// Rendert die druckfertige Karte (Vorder-/Rückseite) als Bild für die Vorschau.
async function renderPreview(employeeId: string, productId: string, employeeName: string, productName: string) {
preview.value = { employee: employeeName, product: productName, loading: true, pages: [] }
try {
const res = await client.get(`/employees/${employeeId}/card.pdf`, { responseType: 'arraybuffer', params: { product: productId } })
const doc = await pdfjsLib.getDocument({ data: new Uint8Array(res.data as ArrayBuffer) }).promise
const pages: string[] = []
for (let p = 1; p <= doc.numPages; p++) {
const page = await doc.getPage(p)
const base = page.getViewport({ scale: 1 })
const vp = page.getViewport({ scale: 300 / base.width })
const canvas = document.createElement('canvas')
canvas.width = vp.width
canvas.height = vp.height
await page.render({ canvas, canvasContext: canvas.getContext('2d')!, viewport: vp }).promise
pages.push(canvas.toDataURL('image/png'))
}
if (preview.value) { preview.value.pages = pages; preview.value.loading = false }
} catch {
if (preview.value) preview.value.loading = false
}
}
function previewRow(row: { product: string; employee: string }) {
if (!row.product || !row.employee) return
const prod = products.value.find((p) => p['@id'] === row.product)
const emp = employees.value.find((e) => e['@id'] === row.employee)
renderPreview(idOf(row.employee), idOf(row.product), emp ? `${emp.firstName} ${emp.lastName}` : '', prod?.name ?? '')
}
function previewDetailItem(it: OrderItem) {
renderPreview(it.employee.id, it.product.id, it.employee.name, it.product.name)
}
function fmtDate(s: string) { return new Date(s).toLocaleDateString('de-DE') }
onMounted(async () => { await load(); if (canOrder.value) await loadRefs() })
</script>
<template>
<section>
<div class="page-head">
<div>
<h1>Bestellungen</h1>
<p class="muted">{{ canFulfill ? 'Eingehende Druckaufträge abwickeln' : 'Produkte für Mitarbeiter bestellen' }}</p>
</div>
<button v-if="canOrder" class="btn btn-primary" @click="openCreate">+ Neue Bestellung</button>
</div>
<div class="card">
<table class="tbl">
<thead>
<tr>
<th>Nr.</th>
<th v-if="showCompanyCol">Firma</th>
<th>Status</th><th>Positionen</th><th>Menge</th><th>Datum</th><th></th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td :colspan="showCompanyCol ? 7 : 6" class="empty">Lädt</td></tr>
<tr v-else-if="!orders.length"><td :colspan="showCompanyCol ? 7 : 6" class="empty">Noch keine Bestellungen.</td></tr>
<tr v-for="o in orders" :key="o.id" class="row" @click="openDetail(o)">
<td><strong>{{ o.number }}</strong></td>
<td v-if="showCompanyCol">{{ o.company.name }}</td>
<td><span class="badge" :class="STATUS_CLASS[o.status]">{{ STATUS_LABEL[o.status] }}</span></td>
<td class="muted">{{ o.itemCount }}</td>
<td>{{ o.totalQuantity }}</td>
<td class="muted">{{ fmtDate(o.createdAt) }}</td>
<td class="right"><button class="btn btn-ghost btn-sm" @click.stop="openDetail(o)">Details</button></td>
</tr>
</tbody>
</table>
</div>
<!-- Neue Bestellung -->
<Modal v-if="showCreate" title="Neue Bestellung" wide @close="showCreate = false">
<form @submit.prevent="submit">
<div class="items">
<div class="items-head"><span>Produkt</span><span>Mitarbeiter</span><span>Menge</span><span></span><span></span></div>
<div v-for="(row, i) in form.items" :key="i" class="item-row">
<select class="input" v-model="row.product" required>
<option v-for="p in products" :key="p['@id']" :value="p['@id']">{{ p.name }}</option>
</select>
<select class="input" v-model="row.employee" required>
<option value="" disabled> wählen </option>
<option v-for="e in employees" :key="e['@id']" :value="e['@id']">{{ e.firstName }} {{ e.lastName }}</option>
</select>
<input class="input qty" type="number" min="1" v-model.number="row.quantity" />
<button type="button" class="btn btn-ghost btn-sm eye" :disabled="!row.product || !row.employee" @click="previewRow(row)" title="Vorschau">👁</button>
<button type="button" class="btn btn-ghost btn-sm del" :disabled="form.items.length === 1" @click="removeRow(i)" title="Position entfernen"></button>
</div>
</div>
<button type="button" class="btn btn-soft btn-sm addrow" @click="addRow">+ Position</button>
<div class="field">
<label>Notiz (optional)</label>
<input class="input" v-model="form.note" placeholder="z. B. Erstausstattung neues Team" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showCreate = false">Abbrechen</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Bestellen…' : 'Bestellen' }}</button>
</div>
</form>
</Modal>
<!-- Detail -->
<Modal v-if="detail" :title="`Bestellung ${detail.number}`" @close="detail = null">
<div class="detail-meta">
<span class="badge" :class="STATUS_CLASS[detail.status]">{{ STATUS_LABEL[detail.status] }}</span>
<span class="muted">{{ detail.company.name }} · {{ fmtDate(detail.createdAt) }}<template v-if="detail.createdBy"> · {{ detail.createdBy }}</template></span>
</div>
<p v-if="detail.note" class="note">{{ detail.note }}"</p>
<table class="tbl items-tbl">
<thead><tr><th>Produkt</th><th>Mitarbeiter</th><th>Menge</th><th></th></tr></thead>
<tbody>
<tr v-for="it in detail.items" :key="it.id">
<td><strong>{{ it.product.name }}</strong></td>
<td>{{ it.employee.name }}</td>
<td>{{ it.quantity }}</td>
<td class="right">
<button class="btn btn-ghost btn-sm" @click="previewDetailItem(it)">Vorschau</button>
<button class="btn btn-ghost btn-sm" @click="openPdf(it)">PDF</button>
</td>
</tr>
</tbody>
</table>
<div class="status-actions">
<!-- Reseller/Plattform: Status vorwärts -->
<button v-if="canFulfill && NEXT[detail.status]" class="btn btn-primary btn-sm" :disabled="busy"
@click="setStatus(NEXT[detail.status])">
{{ STATUS_LABEL[NEXT[detail.status]] }}
</button>
<!-- Stornieren: Reseller jederzeit, Firma solange neu" -->
<button v-if="(canFulfill || (canOrder && detail.status === 'new')) && detail.status !== 'cancelled' && detail.status !== 'completed'"
class="btn btn-ghost btn-sm cancel" :disabled="busy" @click="setStatus('cancelled')">
Stornieren
</button>
</div>
</Modal>
<!-- Vorschau der gerenderten Karte -->
<Modal v-if="preview" :title="`Vorschau · ${preview.employee}`" wide @close="preview = null">
<p class="muted prev-sub">{{ preview.product }}</p>
<div v-if="preview.loading" class="prev-loading">Vorschau wird gerendert</div>
<div v-else-if="!preview.pages.length" class="prev-loading">Keine Vorschau verfügbar.</div>
<div v-else class="prev-pages">
<figure v-for="(src, i) in preview.pages" :key="i">
<img :src="src" alt="Kartenvorschau" />
<figcaption class="muted">{{ i === 0 ? 'Vorderseite' : 'Rückseite' }}</figcaption>
</figure>
</div>
<p class="muted prev-note">Druckdaten inkl. Beschnitt &amp; Schnittmarken so wird gedruckt.</p>
</Modal>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: .65rem .8rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
.tbl td { padding: .7rem .8rem; border-bottom: 1px solid #f4f4f4; }
.tbl tr:last-child td { border-bottom: none; }
.row { cursor: pointer; }
.row:hover { background: #fafafa; }
.right { text-align: right; white-space: nowrap; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.error { color: var(--danger); font-size: .88rem; }
.badge { font-size: .74rem; font-weight: 700; padding: .15rem .55rem; border-radius: 999px; }
.s-new { background: #eef2ff; color: #3730a3; }
.s-prod { background: #fff3e6; color: var(--psc-orange-dark); }
.s-ship { background: #e0f2fe; color: #075985; }
.s-done { background: #e7f8ec; color: #1b7a3d; }
.s-cancel { background: #f3f4f6; color: #6b7280; }
.items { border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
.items-head, .item-row { display: grid; grid-template-columns: 1fr 1fr 64px 32px 32px; gap: .4rem; align-items: center; padding: .5rem .6rem; }
.items-head { font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); background: #fafafa; }
.item-row { border-top: 1px solid #f4f4f4; }
.items-head > *, .item-row > * { min-width: 0; }
.item-row :deep(.input) { padding-left: .5rem; padding-right: .3rem; }
.item-row .qty { text-align: right; }
.item-row .eye, .item-row .del { padding: 0; }
.item-row .del { color: var(--danger); }
.prev-sub { margin: -.4rem 0 1rem; }
.prev-loading { padding: 2rem; text-align: center; color: var(--muted); }
.prev-pages { display: flex; flex-wrap: wrap; gap: 1.2rem; justify-content: center; }
.prev-pages figure { margin: 0; text-align: center; }
.prev-pages img { width: 300px; max-width: 100%; border: 1px solid var(--line); border-radius: 6px; box-shadow: var(--shadow-sm); background: #fff; }
.prev-pages figcaption { font-size: .78rem; margin-top: .4rem; }
.prev-note { font-size: .8rem; margin: 1.1rem 0 0; text-align: center; }
.item-row .del { color: var(--danger); }
.addrow { margin: .6rem 0 1rem; }
.field { margin-top: .4rem; }
.detail-meta { display: flex; align-items: center; gap: .7rem; margin-bottom: .6rem; }
.note { font-style: italic; color: var(--muted); margin: 0 0 .8rem; }
.items-tbl { margin-bottom: 1rem; }
.status-actions { display: flex; gap: .6rem; justify-content: flex-end; }
.status-actions .cancel { color: var(--danger); }
</style>

View File

@ -1,234 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { list, create, update, remove } from '@/api/resources'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
interface Product {
'@id': string
id: string
kind: string
name: string
description: string | null
widthMm: number
heightMm: number
bleedMm: number
safeMm: number
sides: number
nfcEnabled: boolean
printEnabled: boolean
active: boolean
sortOrder: number
global: boolean
}
const KIND_LABEL: Record<string, string> = {
business_card: 'Visitenkarte',
name_tag: 'Namensschild',
nfc_card: 'NFC-Karte',
}
// Sinnvolle Format-Vorgaben je Produktart (KONZEPT §13)
const KIND_DEFAULTS: Record<string, Partial<Product>> = {
business_card: { widthMm: 85, heightMm: 55, bleedMm: 2, safeMm: 4, sides: 2, nfcEnabled: false },
name_tag: { widthMm: 90, heightMm: 55, bleedMm: 0, safeMm: 3, sides: 1, nfcEnabled: false },
nfc_card: { widthMm: 85.6, heightMm: 54, bleedMm: 2, safeMm: 4, sides: 2, nfcEnabled: true },
}
const auth = useAuthStore()
const products = ref<Product[]>([])
const loading = ref(true)
const error = ref('')
const saving = ref(false)
const showEdit = ref(false)
const editing = ref<Product | null>(null)
const blank = () => ({
kind: 'business_card', name: '', description: '',
widthMm: 85, heightMm: 55, bleedMm: 2, safeMm: 4,
sides: 2, nfcEnabled: false, printEnabled: true, active: true, sortOrder: 0,
})
const form = ref<ReturnType<typeof blank>>(blank())
// Reseller sehen nur global + eigene; alles Nicht-Globale ist also editierbar.
function editable(p: Product) {
return auth.isPlatformAdmin || !p.global
}
const sorted = computed(() =>
[...products.value].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)),
)
async function load() {
loading.value = true
products.value = (await list<Product>('products')).member
loading.value = false
}
function applyKindDefaults() {
Object.assign(form.value, KIND_DEFAULTS[form.value.kind] ?? {})
}
function openCreate() {
editing.value = null
form.value = blank()
error.value = ''
showEdit.value = true
}
function openEdit(p: Product) {
editing.value = p
form.value = {
kind: p.kind, name: p.name, description: p.description ?? '',
widthMm: p.widthMm, heightMm: p.heightMm, bleedMm: p.bleedMm, safeMm: p.safeMm,
sides: p.sides, nfcEnabled: p.nfcEnabled, printEnabled: p.printEnabled,
active: p.active, sortOrder: p.sortOrder,
}
error.value = ''
showEdit.value = true
}
async function submit() {
error.value = ''
saving.value = true
const payload = {
kind: form.value.kind,
name: form.value.name,
description: form.value.description || null,
widthMm: Number(form.value.widthMm),
heightMm: Number(form.value.heightMm),
bleedMm: Number(form.value.bleedMm),
safeMm: Number(form.value.safeMm),
sides: Number(form.value.sides),
nfcEnabled: form.value.nfcEnabled,
printEnabled: form.value.printEnabled,
active: form.value.active,
sortOrder: Number(form.value.sortOrder),
}
try {
if (editing.value) await update(editing.value['@id'], payload)
else await create('products', payload)
showEdit.value = false
await load()
} catch {
error.value = 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
async function del(p: Product) {
if (!confirm(`Produkt „${p.name}" wirklich löschen?`)) return
await remove(p['@id'])
await load()
}
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div>
<h1>Produkte</h1>
<p class="muted">
{{ auth.isPlatformAdmin ? 'Globale Produkte für alle Reseller' : 'Eigene Produkte (globale sind read-only)' }}
</p>
</div>
<button class="btn btn-primary" @click="openCreate">+ Produkt hinzufügen</button>
</div>
<div class="card">
<table class="tbl">
<thead>
<tr><th>Name</th><th>Art</th><th>Format</th><th>Seiten</th><th>NFC</th><th>Sichtbarkeit</th><th></th></tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="7" class="empty">Lädt</td></tr>
<tr v-else-if="!sorted.length"><td colspan="7" class="empty">Noch keine Produkte.</td></tr>
<tr v-for="p in sorted" :key="p.id">
<td><strong>{{ p.name }}</strong><span v-if="!p.active" class="muted sm"> · inaktiv</span></td>
<td>{{ KIND_LABEL[p.kind] ?? p.kind }}</td>
<td class="muted nowrap">{{ p.widthMm }}×{{ p.heightMm }} mm<span v-if="p.bleedMm"> · {{ p.bleedMm }} Bl.</span></td>
<td>{{ p.sides === 2 ? 'V/R' : 'nur V' }}</td>
<td>{{ p.nfcEnabled ? '✓' : '' }}</td>
<td>
<span class="badge" :class="p.global ? 'badge-global' : 'badge-own'">{{ p.global ? 'Global' : 'Eigen' }}</span>
</td>
<td class="right">
<template v-if="editable(p)">
<button class="btn btn-ghost btn-sm" @click="openEdit(p)">Bearbeiten</button>
<button class="btn btn-ghost btn-sm del-btn" title="Löschen" @click="del(p)"></button>
</template>
<span v-else class="muted sm" title="Globales Produkt nur Plattform"></span>
</td>
</tr>
</tbody>
</table>
</div>
<Modal v-if="showEdit" :title="editing ? 'Produkt bearbeiten' : 'Produkt hinzufügen'" @close="showEdit = false">
<form @submit.prevent="submit">
<div class="grid2">
<div class="field">
<label>Produktart</label>
<select class="input" v-model="form.kind" :disabled="!!editing" @change="applyKindDefaults">
<option value="business_card">Visitenkarte</option>
<option value="name_tag">Namensschild</option>
<option value="nfc_card">NFC-Karte</option>
</select>
</div>
<div class="field">
<label>Name</label>
<input class="input" v-model="form.name" required placeholder="z. B. Premium-Visitenkarte" />
</div>
</div>
<div class="field">
<label>Beschreibung (optional)</label>
<input class="input" v-model="form.description" placeholder="Kurzbeschreibung für die Auswahl" />
</div>
<div class="grid4">
<div class="field"><label>Breite (mm)</label><input class="input" type="number" step="0.1" v-model="form.widthMm" /></div>
<div class="field"><label>Höhe (mm)</label><input class="input" type="number" step="0.1" v-model="form.heightMm" /></div>
<div class="field"><label>Beschnitt (mm)</label><input class="input" type="number" step="0.1" v-model="form.bleedMm" /></div>
<div class="field"><label>Sicherheit (mm)</label><input class="input" type="number" step="0.1" v-model="form.safeMm" /></div>
</div>
<div class="grid4">
<div class="field">
<label>Seiten</label>
<select class="input" v-model.number="form.sides"><option :value="1">nur Vorderseite</option><option :value="2">Vorder-/Rückseite</option></select>
</div>
<div class="field"><label>Reihenfolge</label><input class="input" type="number" v-model="form.sortOrder" /></div>
<label class="check"><input type="checkbox" v-model="form.nfcEnabled" /> NFC</label>
<label class="check"><input type="checkbox" v-model="form.active" /> Aktiv</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showEdit = false">Abbrechen</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Speichern' }}</button>
</div>
</form>
</Modal>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: .65rem .6rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
.tbl td { padding: .65rem .6rem; border-bottom: 1px solid #f4f4f4; }
.del-btn { color: var(--danger); padding-left: .5rem; padding-right: .5rem; }
.tbl tr:last-child td { border-bottom: none; }
.nowrap { white-space: nowrap; }
.right { text-align: right; white-space: nowrap; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.error { color: var(--danger); font-size: .88rem; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
.grid4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; align-items: end; }
.check { display: flex; align-items: center; gap: .4rem; font-size: .9rem; font-weight: 600; padding-bottom: .6rem; }
.badge-global { background: #eef2ff; color: #3730a3; }
.badge-own { background: #fff3e6; color: var(--psc-orange-dark); }
.sm { font-size: .8rem; }
</style>