mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
Merge pull request #4 from Since-3/feature/added-stammdaten
added stammdaten form
This commit is contained in:
commit
f218d472fa
22 changed files with 2734 additions and 7 deletions
37
.github/workflows/ci.yml
vendored
Normal file
37
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
name: CI Pipeline for S3-Portal
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
|
||||||
|
SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }}
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: Build, Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🧩 Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🛠 Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: 🔍 Lint code
|
||||||
|
run: npm run lint --workspaces=false
|
||||||
|
|
||||||
|
- name: 🏗 Build Next.js app
|
||||||
|
run: npm run build
|
||||||
10
app/admin/clients/[id]/data/page.tsx
Normal file
10
app/admin/clients/[id]/data/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import StammdatenAdminViewPage from "@/modules/admin/ui/views/stammdaten-admin-view"
|
||||||
|
|
||||||
|
type Params = Promise<{ id: string }>;
|
||||||
|
|
||||||
|
const DataAdminPage = async ({ params }: { params: Params }) => {
|
||||||
|
const { id } = await params
|
||||||
|
return <StammdatenAdminViewPage clientProfileId={id} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataAdminPage
|
||||||
|
|
@ -2,15 +2,17 @@ import { cookies } from "next/headers";
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { verifySession } from "@/lib/supabase/dal";
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
|
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
|
||||||
|
import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog";
|
||||||
|
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await verifySession()
|
const session = await verifySession()
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
|
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={defaultOpen}>
|
<SidebarProvider defaultOpen={defaultOpen}>
|
||||||
|
{!session.isAdmin && !session.hasAcceptedTerms && <TermsDialog/>}
|
||||||
<AppSidebar session={session}/>
|
<AppSidebar session={session}/>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div className="flex items-center border-b border-border p-3 md:hidden">
|
<div className="flex items-center border-b border-border p-3 md:hidden">
|
||||||
|
|
|
||||||
7
app/client/data/page.tsx
Normal file
7
app/client/data/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import StammdatenClientViewPage from "@/modules/client/ui/views/stammdaten-client-view"
|
||||||
|
|
||||||
|
const DataPage = () => {
|
||||||
|
return <StammdatenClientViewPage/>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataPage
|
||||||
|
|
@ -2,6 +2,7 @@ import { cookies } from "next/headers";
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { verifySession } from "@/lib/supabase/dal";
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
|
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar";
|
||||||
|
import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog";
|
||||||
|
|
||||||
|
|
||||||
export default async function ClientLayout({ children }: { children: React.ReactNode }) {
|
export default async function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -9,6 +10,10 @@ export default async function ClientLayout({ children }: { children: React.React
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
|
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
|
||||||
|
|
||||||
|
if (!session.isAdmin && !session.hasAcceptedTerms) {
|
||||||
|
return <TermsDialog />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={defaultOpen}>
|
<SidebarProvider defaultOpen={defaultOpen}>
|
||||||
<AppSidebar session={session}/>
|
<AppSidebar session={session}/>
|
||||||
|
|
|
||||||
12
app/terms/page.tsx
Normal file
12
app/terms/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
const TermsPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl p-8">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">Nutzungsbedingungen</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
TODO: Nutzungsbedingungen einfügen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TermsPage
|
||||||
|
|
@ -18,7 +18,7 @@ export const getProfile = cache(async () => {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select("role, name, email")
|
.select("role, name, email, terms_accepted_at")
|
||||||
.eq("id", user.id)
|
.eq("id", user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|
@ -41,5 +41,6 @@ export const verifySession = cache(async () => {
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
isAdmin: profile.role === "admin",
|
isAdmin: profile.role === "admin",
|
||||||
isClient: profile.role === "client",
|
isClient: profile.role === "client",
|
||||||
|
hasAcceptedTerms: profile.terms_accepted_at != null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
543
lib/supabase/migrations/20260928092016_create_db_schema.sql
Normal file
543
lib/supabase/migrations/20260928092016_create_db_schema.sql
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- Client-Portal – Datenbankschema (Supabase / PostgreSQL 15+)
|
||||||
|
-- Voraussetzung: public.profiles existiert bereits
|
||||||
|
-- (id, email, name, role, created_at; id -> auth.users.id)
|
||||||
|
-- Falls du bereits einen Trigger auf auth.users hast, VORHER droppen
|
||||||
|
-- (siehe Abschnitt 3), sonst wird das Profil doppelt angelegt.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 0. Extensions & generische Helfer
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Vault wird für verschlüsselte Passwort-Ablage in Abschnitt 5 gebraucht.
|
||||||
|
create extension if not exists supabase_vault;
|
||||||
|
|
||||||
|
create type public.payment_method as enum ('invoice', 'sepa_debit', 'card');
|
||||||
|
create type public.invoice_status as enum ('pending', 'paid', 'cancelled'); -- "fällig" wird abgeleitet (siehe View)
|
||||||
|
create type public.product_category as enum ('website', 'email', 'seo', 'other');
|
||||||
|
create type public.product_status as enum ('online', 'maintenance', 'offline');
|
||||||
|
create type public.task_status as enum ('in_progress', 'planned', 'done', 'cancelled');
|
||||||
|
|
||||||
|
create or replace function public.set_updated_at()
|
||||||
|
returns trigger language plpgsql set search_path = '' as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = now();
|
||||||
|
return new;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 1. Clients (Stammdaten)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create table public.clients (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Firma
|
||||||
|
company_name text not null,
|
||||||
|
legal_form text not null, -- GmbH, UG, GbR, e.K., ...
|
||||||
|
industry text,
|
||||||
|
vat_id text, -- USt-IdNr
|
||||||
|
tax_number text, -- Steuernummer
|
||||||
|
commercial_register_number text, -- optional
|
||||||
|
logo_path text, -- Pfad im Storage-Bucket, keine öffentliche URL
|
||||||
|
|
||||||
|
-- Firmenadresse
|
||||||
|
street text not null,
|
||||||
|
postal_code text not null,
|
||||||
|
city text not null,
|
||||||
|
country char(2) not null default 'DE',
|
||||||
|
|
||||||
|
-- abweichende Rechnungsadresse (alles NULL = identisch mit Firmenadresse)
|
||||||
|
billing_street text,
|
||||||
|
billing_postal_code text,
|
||||||
|
billing_city text,
|
||||||
|
billing_country char(2),
|
||||||
|
|
||||||
|
-- Ansprechpartner
|
||||||
|
contact_name text not null,
|
||||||
|
contact_position text,
|
||||||
|
contact_email text not null,
|
||||||
|
contact_phone text,
|
||||||
|
|
||||||
|
created_by uuid default auth.uid(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
constraint billing_address_all_or_nothing check (
|
||||||
|
(billing_street is null and billing_postal_code is null and billing_city is null and billing_country is null)
|
||||||
|
or
|
||||||
|
(billing_street is not null and billing_postal_code is not null and billing_city is not null and billing_country is not null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Zahlungsdaten separat: sensibler, eigene Rechte später einfach möglich
|
||||||
|
create table public.client_payment_details (
|
||||||
|
client_id uuid primary key references public.clients(id) on delete cascade,
|
||||||
|
payment_method public.payment_method not null default 'invoice',
|
||||||
|
account_holder text,
|
||||||
|
iban text,
|
||||||
|
bic text,
|
||||||
|
sepa_mandate_ref text,
|
||||||
|
sepa_mandate_date date,
|
||||||
|
card_customer_id text, -- NUR Token/Customer-ID des Zahlungsanbieters (z. B. Stripe), NIE Kartennummern
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
constraint iban_format check (iban is null or iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$'),
|
||||||
|
constraint sepa_needs_iban check (
|
||||||
|
payment_method <> 'sepa_debit' or (iban is not null and account_holder is not null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 2. profiles erweitern (Login-User -> Client)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
alter table public.profiles
|
||||||
|
add column if not exists client_id uuid references public.clients(id) on delete set null,
|
||||||
|
add column if not exists updated_at timestamptz not null default now();
|
||||||
|
|
||||||
|
-- Admins gehören zu keinem Client
|
||||||
|
alter table public.profiles
|
||||||
|
add constraint profiles_admin_has_no_client
|
||||||
|
check ((role = 'admin' and client_id is null) or role = 'client');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 3. Auth-Helfer + Trigger
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function public.is_admin()
|
||||||
|
returns boolean language sql stable security definer set search_path = '' as $$
|
||||||
|
select exists (select 1 from public.profiles where id = auth.uid() and role = 'admin');
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.current_client_id()
|
||||||
|
returns uuid language sql stable security definer set search_path = '' as $$
|
||||||
|
select client_id from public.profiles where id = auth.uid();
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Gehärteter Trigger: Rolle ist IMMER 'client'. Admins werden manuell per SQL gesetzt.
|
||||||
|
-- client_id kommt aus raw_app_meta_data (nur per Service-Role/Admin-API setzbar),
|
||||||
|
-- NICHT aus raw_user_meta_data (die kann ein User beim Signup selbst befüllen).
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger language plpgsql security definer set search_path = '' as $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, email, name, role, client_id)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.email,
|
||||||
|
coalesce(new.raw_user_meta_data->>'name', split_part(new.email, '@', 1)),
|
||||||
|
'client',
|
||||||
|
nullif(new.raw_app_meta_data->>'client_id', '')::uuid
|
||||||
|
);
|
||||||
|
return new;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- drop trigger if exists <dein_alter_trigger> on auth.users;
|
||||||
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row execute function public.handle_new_user();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 4. Rechnungen
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Produkt-Vorlagen für Rechnungspositionen (nur Admin)
|
||||||
|
create table public.product_templates (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
unit_price numeric(12,2) not null check (unit_price >= 0), -- netto
|
||||||
|
tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100),
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lückenloser Nummernkreis pro Jahr (nur über next_invoice_number() nutzbar)
|
||||||
|
create table public.invoice_counters (
|
||||||
|
year int primary key,
|
||||||
|
last_number int not null default 0
|
||||||
|
);
|
||||||
|
alter table public.invoice_counters enable row level security; -- bewusst keine Policies
|
||||||
|
|
||||||
|
create or replace function public.next_invoice_number()
|
||||||
|
returns text language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
y int := extract(year from current_date)::int;
|
||||||
|
n int;
|
||||||
|
begin
|
||||||
|
if not public.is_admin() then
|
||||||
|
raise exception 'forbidden' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
insert into public.invoice_counters as c (year, last_number)
|
||||||
|
values (y, 1)
|
||||||
|
on conflict (year) do update set last_number = c.last_number + 1
|
||||||
|
returning c.last_number into n;
|
||||||
|
return format('RE-%s-%s', y, lpad(n::text, 4, '0'));
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create table public.invoices (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid not null references public.clients(id) on delete restrict,
|
||||||
|
invoice_number text not null unique default public.next_invoice_number(),
|
||||||
|
invoice_date date not null default current_date,
|
||||||
|
due_date date not null,
|
||||||
|
status public.invoice_status not null default 'pending',
|
||||||
|
paid_at date,
|
||||||
|
|
||||||
|
-- Snapshot der Kundendaten zum Rechnungszeitpunkt (werden per Trigger befüllt)
|
||||||
|
company_name text not null,
|
||||||
|
vat_id text,
|
||||||
|
billing_street text not null,
|
||||||
|
billing_postal_code text not null,
|
||||||
|
billing_city text not null,
|
||||||
|
billing_country char(2) not null,
|
||||||
|
|
||||||
|
notes text,
|
||||||
|
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
constraint due_after_invoice_date check (due_date >= invoice_date),
|
||||||
|
constraint paid_has_date check (status <> 'paid' or paid_at is not null)
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.fill_invoice_snapshot()
|
||||||
|
returns trigger language plpgsql set search_path = '' as $$
|
||||||
|
declare c public.clients%rowtype;
|
||||||
|
begin
|
||||||
|
select * into c from public.clients where id = new.client_id;
|
||||||
|
new.company_name := coalesce(new.company_name, c.company_name);
|
||||||
|
new.vat_id := coalesce(new.vat_id, c.vat_id);
|
||||||
|
new.billing_street := coalesce(new.billing_street, c.billing_street, c.street);
|
||||||
|
new.billing_postal_code := coalesce(new.billing_postal_code, c.billing_postal_code, c.postal_code);
|
||||||
|
new.billing_city := coalesce(new.billing_city, c.billing_city, c.city);
|
||||||
|
new.billing_country := coalesce(new.billing_country, c.billing_country, c.country);
|
||||||
|
return new;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create trigger fill_invoice_snapshot
|
||||||
|
before insert on public.invoices
|
||||||
|
for each row execute function public.fill_invoice_snapshot();
|
||||||
|
|
||||||
|
create table public.invoice_items (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
invoice_id uuid not null references public.invoices(id) on delete cascade,
|
||||||
|
position int not null,
|
||||||
|
product_template_id uuid references public.product_templates(id) on delete set null,
|
||||||
|
description text not null, -- Produktname (Snapshot aus Vorlage)
|
||||||
|
quantity numeric(10,2) not null default 1 check (quantity > 0),
|
||||||
|
unit_price numeric(12,2) not null, -- netto
|
||||||
|
tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100),
|
||||||
|
unique (invoice_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Summen + abgeleiteter Status "Fällig" (kein Cronjob nötig)
|
||||||
|
create view public.invoices_overview with (security_invoker = true) as
|
||||||
|
select
|
||||||
|
i.*,
|
||||||
|
coalesce(t.net_total, 0) as net_total,
|
||||||
|
coalesce(t.tax_total, 0) as tax_total,
|
||||||
|
coalesce(t.net_total, 0) + coalesce(t.tax_total, 0) as gross_total,
|
||||||
|
case when i.status = 'pending' and i.due_date < current_date
|
||||||
|
then 'overdue' else i.status::text end as display_status
|
||||||
|
from public.invoices i
|
||||||
|
left join lateral (
|
||||||
|
select sum(round(quantity * unit_price, 2)) as net_total,
|
||||||
|
sum(round(quantity * unit_price * tax_rate / 100, 2)) as tax_total
|
||||||
|
from public.invoice_items ii
|
||||||
|
where ii.invoice_id = i.id
|
||||||
|
) t on true;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 5. Passwörter (Supabase Vault = verschlüsselt at rest)
|
||||||
|
-- In der Tabelle liegt NIE das Passwort, nur die Vault-Referenz.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create table public.credentials (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid not null references public.clients(id) on delete cascade,
|
||||||
|
service text not null, -- Vercel, Hostinger, ...
|
||||||
|
username text not null, -- E-Mail / Username
|
||||||
|
vault_secret_id uuid not null, -- -> vault.secrets
|
||||||
|
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||||
|
updated_by uuid references public.profiles(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public.credential_access_log (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
credential_id uuid not null, -- bewusst ohne FK: Log bleibt bei Löschung erhalten
|
||||||
|
user_id uuid,
|
||||||
|
action text not null default 'reveal',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.create_credential(
|
||||||
|
p_client_id uuid, p_service text, p_username text, p_password text
|
||||||
|
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare v_secret uuid; v_id uuid;
|
||||||
|
begin
|
||||||
|
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
|
||||||
|
v_secret := vault.create_secret(p_password, 'cred_' || gen_random_uuid()::text, p_service);
|
||||||
|
insert into public.credentials (client_id, service, username, vault_secret_id, updated_by)
|
||||||
|
values (p_client_id, p_service, p_username, v_secret, auth.uid())
|
||||||
|
returning id into v_id;
|
||||||
|
return v_id;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- p_password = NULL -> Passwort bleibt unverändert
|
||||||
|
create or replace function public.update_credential(
|
||||||
|
p_id uuid, p_service text, p_username text, p_password text default null
|
||||||
|
) returns void language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare v_secret uuid;
|
||||||
|
begin
|
||||||
|
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
|
||||||
|
update public.credentials
|
||||||
|
set service = p_service, username = p_username, updated_by = auth.uid()
|
||||||
|
where id = p_id
|
||||||
|
returning vault_secret_id into v_secret;
|
||||||
|
if not found then raise exception 'credential not found'; end if;
|
||||||
|
if p_password is not null and p_password <> '' then
|
||||||
|
perform vault.update_secret(v_secret, p_password);
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Klartext nur über diese Funktion, jeder Abruf wird protokolliert.
|
||||||
|
-- Erlaubt: Admins + der zugehörige Client. (Client-Zugriff entfernen: "or v_client = ..." löschen)
|
||||||
|
create or replace function public.reveal_credential(p_id uuid)
|
||||||
|
returns text language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare v_client uuid; v_secret uuid; v_pw text;
|
||||||
|
begin
|
||||||
|
select client_id, vault_secret_id into v_client, v_secret
|
||||||
|
from public.credentials where id = p_id;
|
||||||
|
if not found then raise exception 'credential not found'; end if;
|
||||||
|
if not (public.is_admin() or v_client = public.current_client_id()) then
|
||||||
|
raise exception 'forbidden' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
select decrypted_secret into v_pw from vault.decrypted_secrets where id = v_secret;
|
||||||
|
insert into public.credential_access_log (credential_id, user_id) values (p_id, auth.uid());
|
||||||
|
return v_pw;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Vault-Eintrag aufräumen, wenn Credential (oder ganzer Client) gelöscht wird
|
||||||
|
create or replace function public.delete_vault_secret()
|
||||||
|
returns trigger language plpgsql security definer set search_path = '' as $$
|
||||||
|
begin
|
||||||
|
delete from vault.secrets where id = old.vault_secret_id;
|
||||||
|
return old;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create trigger credentials_delete_vault_secret
|
||||||
|
after delete on public.credentials
|
||||||
|
for each row execute function public.delete_vault_secret();
|
||||||
|
|
||||||
|
revoke execute on function public.create_credential(uuid, text, text, text) from public, anon;
|
||||||
|
revoke execute on function public.update_credential(uuid, text, text, text) from public, anon;
|
||||||
|
revoke execute on function public.reveal_credential(uuid) from public, anon;
|
||||||
|
revoke execute on function public.next_invoice_number() from public, anon;
|
||||||
|
grant execute on function public.create_credential(uuid, text, text, text) to authenticated;
|
||||||
|
grant execute on function public.update_credential(uuid, text, text, text) to authenticated;
|
||||||
|
grant execute on function public.reveal_credential(uuid) to authenticated;
|
||||||
|
grant execute on function public.next_invoice_number() to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 6. Dokumente (Metadaten in DB, Dateien in Storage)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create table public.folders (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid not null references public.clients(id) on delete cascade,
|
||||||
|
parent_id uuid, -- NULL = oberste Ebene
|
||||||
|
name text not null,
|
||||||
|
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (id, client_id),
|
||||||
|
-- Ordner nur löschbar, wenn leer (kein ON DELETE-Verhalten = NO ACTION)
|
||||||
|
foreign key (parent_id, client_id) references public.folders (id, client_id),
|
||||||
|
unique nulls not distinct (client_id, parent_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public.documents (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid not null references public.clients(id) on delete cascade,
|
||||||
|
folder_id uuid, -- NULL = außerhalb von Ordnern
|
||||||
|
name text not null, -- Originaldateiname
|
||||||
|
storage_path text not null unique, -- {client_id}/{document_id}/{dateiname}
|
||||||
|
mime_type text,
|
||||||
|
size_bytes bigint check (size_bytes >= 0),
|
||||||
|
uploaded_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
foreign key (folder_id, client_id) references public.folders (id, client_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into storage.buckets (id, name, public)
|
||||||
|
values ('client-documents', 'client-documents', false)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
|
||||||
|
create policy "client_documents_select" on storage.objects for select to authenticated
|
||||||
|
using (bucket_id = 'client-documents'
|
||||||
|
and ((select public.is_admin())
|
||||||
|
or (storage.foldername(name))[1] = (select public.current_client_id())::text));
|
||||||
|
|
||||||
|
create policy "client_documents_insert" on storage.objects for insert to authenticated
|
||||||
|
with check (bucket_id = 'client-documents'
|
||||||
|
and ((select public.is_admin())
|
||||||
|
or (storage.foldername(name))[1] = (select public.current_client_id())::text));
|
||||||
|
|
||||||
|
create policy "client_documents_delete" on storage.objects for delete to authenticated
|
||||||
|
using (bucket_id = 'client-documents' and (select public.is_admin()));
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 7. Info: Produkte, Vorgänge, interne Notizen
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create table public.client_products (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid not null references public.clients(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
category public.product_category not null default 'website',
|
||||||
|
url text,
|
||||||
|
status public.product_status not null default 'online',
|
||||||
|
uptime_30d numeric(5,2) check (uptime_30d between 0 and 100), -- z. B. 99.98 (aus Monitoring-Tool)
|
||||||
|
uptime_updated_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (id, client_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public.product_tasks (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid not null references public.clients(id) on delete cascade,
|
||||||
|
product_id uuid not null,
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
status public.task_status not null default 'planned',
|
||||||
|
since date not null default current_date, -- "seit 3 Tagen" (in_progress) bzw. "ab 6. Okt." (planned)
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (id, client_id),
|
||||||
|
foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Statuswechsel setzt "seit" auf heute, außer der Admin setzt das Datum im selben Update explizit
|
||||||
|
create or replace function public.reset_task_since()
|
||||||
|
returns trigger language plpgsql set search_path = '' as $$
|
||||||
|
begin
|
||||||
|
if new.status is distinct from old.status and new.since is not distinct from old.since then
|
||||||
|
new.since := current_date;
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create trigger reset_task_since
|
||||||
|
before update on public.product_tasks
|
||||||
|
for each row execute function public.reset_task_since();
|
||||||
|
|
||||||
|
-- Eigene Tabelle, damit Clients Notizen nie sehen (RLS arbeitet zeilen-, nicht spaltenweise)
|
||||||
|
create table public.internal_notes (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid not null references public.clients(id) on delete cascade,
|
||||||
|
product_id uuid,
|
||||||
|
task_id uuid,
|
||||||
|
body text not null,
|
||||||
|
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade,
|
||||||
|
foreign key (task_id, client_id) references public.product_tasks (id, client_id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 8. Indizes & updated_at-Trigger
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create index on public.profiles (client_id);
|
||||||
|
create index on public.invoices (client_id, invoice_date desc);
|
||||||
|
create index on public.invoice_items (invoice_id);
|
||||||
|
create index on public.credentials (client_id);
|
||||||
|
create index on public.folders (client_id, parent_id);
|
||||||
|
create index on public.documents (client_id, folder_id);
|
||||||
|
create index on public.client_products (client_id);
|
||||||
|
create index on public.product_tasks (product_id);
|
||||||
|
create index on public.product_tasks (client_id);
|
||||||
|
create index on public.internal_notes (client_id);
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare t text;
|
||||||
|
begin
|
||||||
|
foreach t in array array['profiles','clients','client_payment_details','product_templates',
|
||||||
|
'invoices','credentials','client_products','product_tasks'] loop
|
||||||
|
execute format('create trigger set_updated_at before update on public.%I
|
||||||
|
for each row execute function public.set_updated_at()', t);
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 9. Row Level Security
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Ohne Policy = kein Zugriff. Alles nur für eingeloggte User (nie anon).
|
||||||
|
|
||||||
|
-- 9a) Admin darf alles auf diesen Tabellen
|
||||||
|
do $$
|
||||||
|
declare t text;
|
||||||
|
begin
|
||||||
|
foreach t in array array['clients','client_payment_details','product_templates','invoice_items',
|
||||||
|
'folders','documents','client_products','product_tasks','internal_notes'] loop
|
||||||
|
execute format('alter table public.%I enable row level security', t);
|
||||||
|
execute format('create policy admin_all on public.%I for all to authenticated
|
||||||
|
using ((select public.is_admin())) with check ((select public.is_admin()))', t);
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- 9b) profiles: lesen = eigenes Profil oder Admin; ändern = nur Admin (Rolle nicht selbst änderbar)
|
||||||
|
alter table public.profiles enable row level security;
|
||||||
|
create policy profiles_select on public.profiles for select to authenticated
|
||||||
|
using (id = (select auth.uid()) or (select public.is_admin()));
|
||||||
|
create policy profiles_admin_update on public.profiles for update to authenticated
|
||||||
|
using ((select public.is_admin())) with check ((select public.is_admin()));
|
||||||
|
|
||||||
|
-- 9c) Rechnungen: Admin darf lesen/anlegen/ändern, aber NICHT hart löschen (-> Status 'cancelled' = Storno)
|
||||||
|
alter table public.invoices enable row level security;
|
||||||
|
create policy admin_select on public.invoices for select to authenticated using ((select public.is_admin()));
|
||||||
|
create policy admin_insert on public.invoices for insert to authenticated with check ((select public.is_admin()));
|
||||||
|
create policy admin_update on public.invoices for update to authenticated
|
||||||
|
using ((select public.is_admin())) with check ((select public.is_admin()));
|
||||||
|
|
||||||
|
-- 9d) Passwörter: Schreiben nur über die Funktionen (Abschnitt 5), Löschen nur Admin
|
||||||
|
alter table public.credentials enable row level security;
|
||||||
|
create policy credentials_select on public.credentials for select to authenticated
|
||||||
|
using ((select public.is_admin()) or client_id = (select public.current_client_id()));
|
||||||
|
create policy credentials_admin_delete on public.credentials for delete to authenticated
|
||||||
|
using ((select public.is_admin()));
|
||||||
|
|
||||||
|
alter table public.credential_access_log enable row level security;
|
||||||
|
create policy access_log_admin_select on public.credential_access_log for select to authenticated
|
||||||
|
using ((select public.is_admin()));
|
||||||
|
|
||||||
|
-- 9e) Clients sehen nur ihre eigenen Daten
|
||||||
|
create policy client_select on public.clients for select to authenticated
|
||||||
|
using (id = (select public.current_client_id()));
|
||||||
|
|
||||||
|
create policy client_select on public.client_payment_details for select to authenticated
|
||||||
|
using (client_id = (select public.current_client_id()));
|
||||||
|
|
||||||
|
create policy client_select on public.invoices for select to authenticated
|
||||||
|
using (client_id = (select public.current_client_id()) and status <> 'cancelled');
|
||||||
|
|
||||||
|
create policy client_select on public.invoice_items for select to authenticated
|
||||||
|
using (exists (select 1 from public.invoices i
|
||||||
|
where i.id = invoice_id
|
||||||
|
and i.client_id = (select public.current_client_id())
|
||||||
|
and i.status <> 'cancelled'));
|
||||||
|
|
||||||
|
create policy client_select on public.folders for select to authenticated
|
||||||
|
using (client_id = (select public.current_client_id()));
|
||||||
|
|
||||||
|
create policy client_select on public.documents for select to authenticated
|
||||||
|
using (client_id = (select public.current_client_id()));
|
||||||
|
|
||||||
|
create policy client_insert on public.documents for insert to authenticated
|
||||||
|
with check (client_id = (select public.current_client_id()) and uploaded_by = (select auth.uid()));
|
||||||
|
|
||||||
|
create policy client_select on public.client_products for select to authenticated
|
||||||
|
using (client_id = (select public.current_client_id()));
|
||||||
|
|
||||||
|
create policy client_select on public.product_tasks for select to authenticated
|
||||||
|
using (client_id = (select public.current_client_id()));
|
||||||
|
|
||||||
|
-- product_templates, internal_notes: für Clients bewusst keine Policy = unsichtbar
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table public.profiles
|
||||||
|
add column if not exists terms_accepted_at timestamptz;
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
-- Legt einen neuen clients-Datensatz an und verknüpft ihn atomar mit profiles.client_id.
|
||||||
|
-- Der "where client_id is null"-Guard verhindert, dass zwei parallele Submits
|
||||||
|
-- jeweils einen eigenen clients-Datensatz erzeugen und verwaisen lassen.
|
||||||
|
create or replace function public.create_client_and_link_profile(
|
||||||
|
p_profile_id uuid,
|
||||||
|
p_company_name text,
|
||||||
|
p_legal_form text,
|
||||||
|
p_industry text,
|
||||||
|
p_vat_id text,
|
||||||
|
p_tax_number text,
|
||||||
|
p_commercial_register_number text,
|
||||||
|
p_street text,
|
||||||
|
p_postal_code text,
|
||||||
|
p_city text,
|
||||||
|
p_country char(2),
|
||||||
|
p_billing_street text,
|
||||||
|
p_billing_postal_code text,
|
||||||
|
p_billing_city text,
|
||||||
|
p_billing_country char(2),
|
||||||
|
p_contact_name text,
|
||||||
|
p_contact_position text,
|
||||||
|
p_contact_email text,
|
||||||
|
p_contact_phone text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_client_id uuid;
|
||||||
|
v_updated_rows int;
|
||||||
|
begin
|
||||||
|
insert into public.clients (
|
||||||
|
company_name, legal_form, industry, vat_id, tax_number, commercial_register_number,
|
||||||
|
street, postal_code, city, country,
|
||||||
|
billing_street, billing_postal_code, billing_city, billing_country,
|
||||||
|
contact_name, contact_position, contact_email, contact_phone
|
||||||
|
) values (
|
||||||
|
p_company_name, p_legal_form, p_industry, p_vat_id, p_tax_number, p_commercial_register_number,
|
||||||
|
p_street, p_postal_code, p_city, p_country,
|
||||||
|
p_billing_street, p_billing_postal_code, p_billing_city, p_billing_country,
|
||||||
|
p_contact_name, p_contact_position, p_contact_email, p_contact_phone
|
||||||
|
)
|
||||||
|
returning id into v_client_id;
|
||||||
|
|
||||||
|
update public.profiles
|
||||||
|
set client_id = v_client_id
|
||||||
|
where id = p_profile_id
|
||||||
|
and client_id is null;
|
||||||
|
|
||||||
|
get diagnostics v_updated_rows = row_count;
|
||||||
|
|
||||||
|
if v_updated_rows = 0 then
|
||||||
|
raise exception 'profile already linked to a client' using errcode = '23505';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return v_client_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke execute on function public.create_client_and_link_profile(
|
||||||
|
uuid, text, text, text, text, text, text, text, text, text, char(2),
|
||||||
|
text, text, text, char(2), text, text, text, text
|
||||||
|
) from public, anon;
|
||||||
|
|
||||||
|
grant execute on function public.create_client_and_link_profile(
|
||||||
|
uuid, text, text, text, text, text, text, text, text, text, char(2),
|
||||||
|
text, text, text, char(2), text, text, text, text
|
||||||
|
) to authenticated;
|
||||||
|
|
@ -52,7 +52,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 w-full">
|
<div className="flex flex-col gap-8 w-full">
|
||||||
<div className="flex flex-row items-center justify-between gap-2 w-full p-2">
|
<div className="flex flex-row items-center justify-between gap-2 w-full p-2">
|
||||||
<InputGroup className="max-w-lg bg-white rounded-md focus-visible:ring-0">
|
<InputGroup className="max-w-lg rounded-md focus-visible:ring-0">
|
||||||
<InputGroupInput
|
<InputGroupInput
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={query}
|
value={query}
|
||||||
|
|
|
||||||
16
modules/admin/ui/views/stammdaten-admin-view.tsx
Normal file
16
modules/admin/ui/views/stammdaten-admin-view.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { getStammdaten } from "@/modules/shared/queries/stammdaten"
|
||||||
|
import { StammdatenPanel } from "@/modules/shared/ui/components/stammdaten-panel"
|
||||||
|
|
||||||
|
const StammdatenAdminViewPage = async ({ clientProfileId }: { clientProfileId: string }) => {
|
||||||
|
const stammdaten = await getStammdaten(clientProfileId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StammdatenPanel
|
||||||
|
profileId={clientProfileId}
|
||||||
|
client={stammdaten?.client ?? null}
|
||||||
|
paymentDetails={stammdaten?.paymentDetails ?? null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StammdatenAdminViewPage
|
||||||
18
modules/client/ui/views/stammdaten-client-view.tsx
Normal file
18
modules/client/ui/views/stammdaten-client-view.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { verifySession } from "@/lib/supabase/dal"
|
||||||
|
import { getStammdaten } from "@/modules/shared/queries/stammdaten"
|
||||||
|
import { StammdatenPanel } from "@/modules/shared/ui/components/stammdaten-panel"
|
||||||
|
|
||||||
|
const StammdatenClientViewPage = async () => {
|
||||||
|
const session = await verifySession()
|
||||||
|
const stammdaten = await getStammdaten(session.userId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StammdatenPanel
|
||||||
|
profileId={session.userId}
|
||||||
|
client={stammdaten?.client ?? null}
|
||||||
|
paymentDetails={stammdaten?.paymentDetails ?? null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StammdatenClientViewPage
|
||||||
310
modules/loginPage/ui/components/stamdaten-form.tsx
Normal file
310
modules/loginPage/ui/components/stamdaten-form.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { saveStammdaten, StammdatenState } from "@/modules/shared/actions/stammdaten";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
CreditCard,
|
||||||
|
FileText,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { LEGAL_FORMS } from "@/modules/shared/data/stammdaten-data";
|
||||||
|
|
||||||
|
type Client = {
|
||||||
|
company_name: string;
|
||||||
|
legal_form: string;
|
||||||
|
industry: string | null;
|
||||||
|
vat_id: string | null;
|
||||||
|
tax_number: string | null;
|
||||||
|
commercial_register_number: string | null;
|
||||||
|
street: string;
|
||||||
|
postal_code: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
billing_street: string | null;
|
||||||
|
billing_postal_code: string | null;
|
||||||
|
billing_city: string | null;
|
||||||
|
billing_country: string | null;
|
||||||
|
contact_name: string;
|
||||||
|
contact_position: string | null;
|
||||||
|
contact_email: string;
|
||||||
|
contact_phone: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type PaymentDetails = {
|
||||||
|
payment_method: "invoice" | "sepa_debit" | "card";
|
||||||
|
account_holder: string | null;
|
||||||
|
iban: string | null;
|
||||||
|
bic: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
const selectClassName =
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
|
function FormSection({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface/50 p-5">
|
||||||
|
<div className="mb-5 flex items-center gap-2">
|
||||||
|
<Icon className="size-4.5 text-muted-foreground" />
|
||||||
|
<h2 className="text-base font-semibold">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<FieldGroup className="@container grid grid-cols-1 gap-4 @md:grid-cols-2">
|
||||||
|
{children}
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
defaultValue,
|
||||||
|
type = "text",
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={name}>
|
||||||
|
{label} {required && <span className="text-destructive">*</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue ?? ""}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
className="focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={name}>
|
||||||
|
{label} {required && <span className="text-destructive">*</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
<select
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onChange={onChange ? (event) => onChange(event.target.value) : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
className={selectClassName}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StammdatenForm = ({
|
||||||
|
profileId,
|
||||||
|
client,
|
||||||
|
paymentDetails,
|
||||||
|
isEditing,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
profileId: string;
|
||||||
|
client: Client;
|
||||||
|
paymentDetails: PaymentDetails;
|
||||||
|
isEditing: boolean;
|
||||||
|
onDone: () => void;
|
||||||
|
}) => {
|
||||||
|
const [state, formAction, pending] = useActionState<StammdatenState, FormData>(
|
||||||
|
saveStammdaten,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState(paymentDetails?.payment_method ?? "invoice");
|
||||||
|
const [hasDifferentBillingAddress, setHasDifferentBillingAddress] = useState(
|
||||||
|
Boolean(client?.billing_street)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state && "success" in state && state.success) {
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
}, [state, onDone]);
|
||||||
|
|
||||||
|
const disabled = !isEditing || pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="flex flex-col gap-6">
|
||||||
|
<input type="hidden" name="profileId" value={profileId} />
|
||||||
|
|
||||||
|
<fieldset disabled={disabled} className="flex flex-col gap-6">
|
||||||
|
<FormSection icon={Building2} title="Firma">
|
||||||
|
<TextField name="companyName" label="Firmenname" defaultValue={client?.company_name} required />
|
||||||
|
<SelectField
|
||||||
|
name="legalForm"
|
||||||
|
label="Rechtsform"
|
||||||
|
defaultValue={client?.legal_form ?? LEGAL_FORMS[0]}
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
options={LEGAL_FORMS.map((form) => ({ value: form, label: form }))}
|
||||||
|
/>
|
||||||
|
<TextField name="industry" label="Branche" defaultValue={client?.industry} />
|
||||||
|
<TextField name="vatId" label="USt-IdNr" defaultValue={client?.vat_id} />
|
||||||
|
<TextField name="taxNumber" label="Steuernummer" defaultValue={client?.tax_number} />
|
||||||
|
<TextField
|
||||||
|
name="commercialRegisterNumber"
|
||||||
|
label="Handelsregisternummer"
|
||||||
|
defaultValue={client?.commercial_register_number}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection icon={MapPin} title="Firmenadresse">
|
||||||
|
<TextField name="street" label="Straße & Hausnummer" defaultValue={client?.street} required />
|
||||||
|
<TextField name="postalCode" label="PLZ" defaultValue={client?.postal_code} required />
|
||||||
|
<TextField name="city" label="Stadt" defaultValue={client?.city} required />
|
||||||
|
<SelectField
|
||||||
|
name="country"
|
||||||
|
label="Land"
|
||||||
|
defaultValue={client?.country ?? "DE"}
|
||||||
|
disabled={disabled}
|
||||||
|
options={[
|
||||||
|
{ value: "DE", label: "Deutschland" },
|
||||||
|
{ value: "AT", label: "Österreich" },
|
||||||
|
{ value: "CH", label: "Schweiz" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-surface/50 p-5">
|
||||||
|
<label className="mb-5 flex items-center gap-2 has-disabled:opacity-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasDifferentBillingAddress}
|
||||||
|
onChange={(event) => setHasDifferentBillingAddress(event.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="size-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<FileText className="size-4.5 text-muted-foreground" />
|
||||||
|
<span className="text-base font-semibold">Abweichende Rechnungsadresse</span>
|
||||||
|
</label>
|
||||||
|
{hasDifferentBillingAddress && (
|
||||||
|
<FieldGroup className="@container grid grid-cols-1 gap-4 @md:grid-cols-2">
|
||||||
|
<TextField
|
||||||
|
name="billingStreet"
|
||||||
|
label="Straße & Hausnummer"
|
||||||
|
defaultValue={client?.billing_street}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="billingPostalCode"
|
||||||
|
label="PLZ"
|
||||||
|
defaultValue={client?.billing_postal_code}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField name="billingCity" label="Stadt" defaultValue={client?.billing_city} required />
|
||||||
|
<TextField
|
||||||
|
name="billingCountry"
|
||||||
|
label="Land"
|
||||||
|
defaultValue={client?.billing_country}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormSection icon={User} title="Ansprechpartner">
|
||||||
|
<TextField name="contactName" label="Name" defaultValue={client?.contact_name} required />
|
||||||
|
<TextField name="contactPosition" label="Position" defaultValue={client?.contact_position} />
|
||||||
|
<TextField name="contactEmail" label="Email" type="email" defaultValue={client?.contact_email} required />
|
||||||
|
<TextField name="contactPhone" label="Telefon" defaultValue={client?.contact_phone} />
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection icon={CreditCard} title="Zahlungsdaten">
|
||||||
|
<SelectField
|
||||||
|
name="paymentMethod"
|
||||||
|
label="Zahlungsart"
|
||||||
|
value={paymentMethod}
|
||||||
|
onChange={(value) => setPaymentMethod(value as typeof paymentMethod)}
|
||||||
|
disabled={disabled}
|
||||||
|
options={[
|
||||||
|
{ value: "invoice", label: "Rechnung" },
|
||||||
|
{ value: "sepa_debit", label: "SEPA-Lastschrift" },
|
||||||
|
{ value: "card", label: "Kreditkarte" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{paymentMethod === "sepa_debit" && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
name="accountHolder"
|
||||||
|
label="Kontoinhaber"
|
||||||
|
defaultValue={paymentDetails?.account_holder}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField name="iban" label="IBAN" defaultValue={paymentDetails?.iban} required />
|
||||||
|
<TextField name="bic" label="BIC" defaultValue={paymentDetails?.bic} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormSection>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{state && "error" in state && (
|
||||||
|
<p className="text-sm text-destructive">{state.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onDone} disabled={pending}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Wird gespeichert..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StammdatenForm;
|
||||||
|
|
@ -19,7 +19,13 @@ const LoginPageView = () => {
|
||||||
width={1617}
|
width={1617}
|
||||||
height={332}
|
height={332}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
className="h-5 w-auto md:h-6"/>
|
className="h-5 w-auto md:h-6 dark:hidden"/>
|
||||||
|
<Image
|
||||||
|
src="/pictures/since3-logo-weiss.svg" alt=""
|
||||||
|
width={1617}
|
||||||
|
height={332}
|
||||||
|
loading="eager"
|
||||||
|
className="hidden h-5 w-auto md:h-6 dark:block"/>
|
||||||
<div className="flex flex-col h-full items-center justify-center">
|
<div className="flex flex-col h-full items-center justify-center">
|
||||||
<h1 className="mb-6 text-3xl font-semibold">Login</h1>
|
<h1 className="mb-6 text-3xl font-semibold">Login</h1>
|
||||||
<form action={formAction} className="w-full max-w-sm ">
|
<form action={formAction} className="w-full max-w-sm ">
|
||||||
|
|
@ -30,7 +36,7 @@ const LoginPageView = () => {
|
||||||
id="form-email"
|
id="form-email"
|
||||||
name="email"
|
name="email"
|
||||||
type="text"
|
type="text"
|
||||||
className="bg-white focus-visible:ring-0"
|
className="focus-visible:ring-0"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -41,7 +47,7 @@ const LoginPageView = () => {
|
||||||
id="form-password"
|
id="form-password"
|
||||||
name="password"
|
name="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="bg-white focus-visible:ring-0 pr-10"
|
className="focus-visible:ring-0 pr-10"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|
@ -67,7 +73,7 @@ const LoginPageView = () => {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-2">
|
<div className="absolute bottom-2">
|
||||||
<p className="text-accent-400 text-sm">Bei Fragen: <a href="mailto:info@since3.de" className="underline">info@since3.de</a></p>
|
<p className="text-muted-foreground text-sm">Bei Fragen: <a href="mailto:info@since3.de" className="underline">info@since3.de</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
22
modules/shared/actions/acceptTerms.ts
Normal file
22
modules/shared/actions/acceptTerms.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
|
|
||||||
|
export async function acceptTerms() {
|
||||||
|
const session = await verifySession();
|
||||||
|
const supabase = createAdminClient();
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ terms_accepted_at: new Date().toISOString() })
|
||||||
|
.eq("id", session.userId)
|
||||||
|
|
||||||
|
if (error) return {
|
||||||
|
error: "Zustimmung konnte nicht gespeichert werden."
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
}
|
||||||
230
modules/shared/actions/stammdaten.ts
Normal file
230
modules/shared/actions/stammdaten.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import * as z from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { verifySession } from "@/lib/supabase/dal";
|
||||||
|
|
||||||
|
const IBAN_REGEX = /^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/;
|
||||||
|
|
||||||
|
const StammdatenSchema = z
|
||||||
|
.object({
|
||||||
|
profileId: z.string().uuid(),
|
||||||
|
|
||||||
|
companyName: z.string().min(1, { error: "Firmenname ist erforderlich." }),
|
||||||
|
legalForm: z.string().min(1, { error: "Rechtsform ist erforderlich." }),
|
||||||
|
industry: z.string().optional(),
|
||||||
|
vatId: z.string().optional(),
|
||||||
|
taxNumber: z.string().optional(),
|
||||||
|
commercialRegisterNumber: z.string().optional(),
|
||||||
|
|
||||||
|
street: z.string().min(1, { error: "Straße ist erforderlich." }),
|
||||||
|
postalCode: z.string().min(1, { error: "PLZ ist erforderlich." }),
|
||||||
|
city: z.string().min(1, { error: "Stadt ist erforderlich." }),
|
||||||
|
country: z.string().length(2).default("DE"),
|
||||||
|
|
||||||
|
billingStreet: z.string().optional(),
|
||||||
|
billingPostalCode: z.string().optional(),
|
||||||
|
billingCity: z.string().optional(),
|
||||||
|
billingCountry: z.string().optional(),
|
||||||
|
|
||||||
|
contactName: z.string().min(1, { error: "Ansprechpartner ist erforderlich." }),
|
||||||
|
contactPosition: z.string().optional(),
|
||||||
|
contactEmail: z.email({ error: "Bitte eine gültige Email eingeben." }),
|
||||||
|
contactPhone: z.string().optional(),
|
||||||
|
|
||||||
|
paymentMethod: z.enum(["invoice", "sepa_debit", "card"]).default("invoice"),
|
||||||
|
accountHolder: z.string().optional(),
|
||||||
|
iban: z.string().optional(),
|
||||||
|
bic: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.paymentMethod === "sepa_debit") {
|
||||||
|
if (!data.accountHolder) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["accountHolder"],
|
||||||
|
message: "Kontoinhaber ist bei SEPA-Lastschrift erforderlich.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.iban) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["iban"],
|
||||||
|
message: "IBAN ist bei SEPA-Lastschrift erforderlich.",
|
||||||
|
});
|
||||||
|
} else if (!IBAN_REGEX.test(data.iban)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["iban"],
|
||||||
|
message: "Bitte eine gültige IBAN eingeben.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingFields = [
|
||||||
|
data.billingStreet,
|
||||||
|
data.billingPostalCode,
|
||||||
|
data.billingCity,
|
||||||
|
data.billingCountry,
|
||||||
|
];
|
||||||
|
const filledCount = billingFields.filter(Boolean).length;
|
||||||
|
|
||||||
|
if (filledCount > 0 && filledCount < billingFields.length) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["billingStreet"],
|
||||||
|
message: "Bitte alle Felder der Rechnungsadresse ausfüllen oder alle leer lassen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StammdatenState = { error: string } | { success: true } | undefined;
|
||||||
|
|
||||||
|
function emptyToUndefined(value: FormDataEntryValue | null) {
|
||||||
|
const stringValue = String(value ?? "").trim();
|
||||||
|
return stringValue === "" ? undefined : stringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveStammdaten(
|
||||||
|
_prevState: StammdatenState,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<StammdatenState> {
|
||||||
|
const session = await verifySession();
|
||||||
|
const profileId = String(formData.get("profileId") ?? "");
|
||||||
|
|
||||||
|
if (!session.isAdmin && session.userId !== profileId) {
|
||||||
|
return { error: "Keine Berechtigung." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedFields = StammdatenSchema.safeParse({
|
||||||
|
profileId,
|
||||||
|
companyName: emptyToUndefined(formData.get("companyName")),
|
||||||
|
legalForm: emptyToUndefined(formData.get("legalForm")),
|
||||||
|
industry: emptyToUndefined(formData.get("industry")),
|
||||||
|
vatId: emptyToUndefined(formData.get("vatId")),
|
||||||
|
taxNumber: emptyToUndefined(formData.get("taxNumber")),
|
||||||
|
commercialRegisterNumber: emptyToUndefined(formData.get("commercialRegisterNumber")),
|
||||||
|
|
||||||
|
street: emptyToUndefined(formData.get("street")),
|
||||||
|
postalCode: emptyToUndefined(formData.get("postalCode")),
|
||||||
|
city: emptyToUndefined(formData.get("city")),
|
||||||
|
country: emptyToUndefined(formData.get("country")) ?? "DE",
|
||||||
|
|
||||||
|
billingStreet: emptyToUndefined(formData.get("billingStreet")),
|
||||||
|
billingPostalCode: emptyToUndefined(formData.get("billingPostalCode")),
|
||||||
|
billingCity: emptyToUndefined(formData.get("billingCity")),
|
||||||
|
billingCountry: emptyToUndefined(formData.get("billingCountry")),
|
||||||
|
|
||||||
|
contactName: emptyToUndefined(formData.get("contactName")),
|
||||||
|
contactPosition: emptyToUndefined(formData.get("contactPosition")),
|
||||||
|
contactEmail: emptyToUndefined(formData.get("contactEmail")),
|
||||||
|
contactPhone: emptyToUndefined(formData.get("contactPhone")),
|
||||||
|
|
||||||
|
paymentMethod: emptyToUndefined(formData.get("paymentMethod")) ?? "invoice",
|
||||||
|
accountHolder: emptyToUndefined(formData.get("accountHolder")),
|
||||||
|
iban: emptyToUndefined(formData.get("iban")),
|
||||||
|
bic: emptyToUndefined(formData.get("bic")),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validatedFields.success) {
|
||||||
|
const { fieldErrors } = z.flattenError(validatedFields.error);
|
||||||
|
const firstError = Object.values(fieldErrors).flat()[0];
|
||||||
|
return { error: firstError ?? "Bitte alle Pflichtfelder ausfüllen." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
billingStreet,
|
||||||
|
billingPostalCode,
|
||||||
|
billingCity,
|
||||||
|
billingCountry,
|
||||||
|
paymentMethod,
|
||||||
|
accountHolder,
|
||||||
|
iban,
|
||||||
|
bic,
|
||||||
|
...data
|
||||||
|
} = validatedFields.data;
|
||||||
|
|
||||||
|
const supabase = createAdminClient();
|
||||||
|
|
||||||
|
const { data: profile, error: profileError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("client_id")
|
||||||
|
.eq("id", data.profileId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profileError || !profile) {
|
||||||
|
return { error: "Profil konnte nicht geladen werden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPayload = {
|
||||||
|
company_name: data.companyName,
|
||||||
|
legal_form: data.legalForm,
|
||||||
|
industry: data.industry ?? null,
|
||||||
|
vat_id: data.vatId ?? null,
|
||||||
|
tax_number: data.taxNumber ?? null,
|
||||||
|
commercial_register_number: data.commercialRegisterNumber ?? null,
|
||||||
|
street: data.street,
|
||||||
|
postal_code: data.postalCode,
|
||||||
|
city: data.city,
|
||||||
|
country: data.country,
|
||||||
|
billing_street: billingStreet ?? null,
|
||||||
|
billing_postal_code: billingPostalCode ?? null,
|
||||||
|
billing_city: billingCity ?? null,
|
||||||
|
billing_country: billingCountry ?? null,
|
||||||
|
contact_name: data.contactName,
|
||||||
|
contact_position: data.contactPosition ?? null,
|
||||||
|
contact_email: data.contactEmail,
|
||||||
|
contact_phone: data.contactPhone ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let clientId = profile.client_id;
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
const { error } = await supabase.from("clients").update(clientPayload).eq("id", clientId);
|
||||||
|
if (error) return { error: "Stammdaten konnten nicht gespeichert werden." };
|
||||||
|
} else {
|
||||||
|
const { data: newClientId, error } = await supabase.rpc("create_client_and_link_profile", {
|
||||||
|
p_profile_id: data.profileId,
|
||||||
|
p_company_name: clientPayload.company_name,
|
||||||
|
p_legal_form: clientPayload.legal_form,
|
||||||
|
p_industry: clientPayload.industry,
|
||||||
|
p_vat_id: clientPayload.vat_id,
|
||||||
|
p_tax_number: clientPayload.tax_number,
|
||||||
|
p_commercial_register_number: clientPayload.commercial_register_number,
|
||||||
|
p_street: clientPayload.street,
|
||||||
|
p_postal_code: clientPayload.postal_code,
|
||||||
|
p_city: clientPayload.city,
|
||||||
|
p_country: clientPayload.country,
|
||||||
|
p_billing_street: clientPayload.billing_street,
|
||||||
|
p_billing_postal_code: clientPayload.billing_postal_code,
|
||||||
|
p_billing_city: clientPayload.billing_city,
|
||||||
|
p_billing_country: clientPayload.billing_country,
|
||||||
|
p_contact_name: clientPayload.contact_name,
|
||||||
|
p_contact_position: clientPayload.contact_position,
|
||||||
|
p_contact_email: clientPayload.contact_email,
|
||||||
|
p_contact_phone: clientPayload.contact_phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !newClientId) return { error: "Stammdaten konnten nicht gespeichert werden." };
|
||||||
|
|
||||||
|
clientId = newClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: paymentError } = await supabase.from("client_payment_details").upsert({
|
||||||
|
client_id: clientId,
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
account_holder: accountHolder ?? null,
|
||||||
|
iban: iban ?? null,
|
||||||
|
bic: bic ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paymentError) {
|
||||||
|
return { error: "Zahlungsdaten konnten nicht gespeichert werden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/client/data");
|
||||||
|
revalidatePath(`/admin/clients/${data.profileId}/data`);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
14
modules/shared/data/stammdaten-data.ts
Normal file
14
modules/shared/data/stammdaten-data.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
|
||||||
|
|
||||||
|
export const LEGAL_FORMS = [
|
||||||
|
"Einzelunternehmen",
|
||||||
|
"GbR",
|
||||||
|
"GmbH",
|
||||||
|
"UG (haftungsbeschränkt)",
|
||||||
|
"AG",
|
||||||
|
"OHG",
|
||||||
|
"KG",
|
||||||
|
"GmbH & Co. KG",
|
||||||
|
"e.K.",
|
||||||
|
"e.V.",
|
||||||
|
];
|
||||||
29
modules/shared/queries/stammdaten.ts
Normal file
29
modules/shared/queries/stammdaten.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import "server-only"
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin"
|
||||||
|
|
||||||
|
export async function getStammdaten(profileId: string) {
|
||||||
|
const supabase = createAdminClient()
|
||||||
|
|
||||||
|
const { data: profile, error: profileError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("client_id")
|
||||||
|
.eq("id", profileId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (profileError || !profile) return null
|
||||||
|
|
||||||
|
if (!profile.client_id) {
|
||||||
|
return { client: null, paymentDetails: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [clientRes, paymentRes] = await Promise.all([
|
||||||
|
supabase.from("clients").select("*").eq("id", profile.client_id).maybeSingle(),
|
||||||
|
supabase.from("client_payment_details").select("*").eq("client_id", profile.client_id).maybeSingle(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (clientRes.error || paymentRes.error) {
|
||||||
|
throw new Error("Stammdaten konnten nicht geladen werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { client: clientRes.data, paymentDetails: paymentRes.data }
|
||||||
|
}
|
||||||
48
modules/shared/ui/components/stammdaten-panel.tsx
Normal file
48
modules/shared/ui/components/stammdaten-panel.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Pen } from "lucide-react";
|
||||||
|
import StammdatenForm from "@/modules/loginPage/ui/components/stamdaten-form";
|
||||||
|
|
||||||
|
type Client = React.ComponentProps<typeof StammdatenForm>["client"];
|
||||||
|
type PaymentDetails = React.ComponentProps<typeof StammdatenForm>["paymentDetails"];
|
||||||
|
|
||||||
|
export function StammdatenPanel({
|
||||||
|
profileId,
|
||||||
|
client,
|
||||||
|
paymentDetails,
|
||||||
|
}: {
|
||||||
|
profileId: string;
|
||||||
|
client: Client;
|
||||||
|
paymentDetails: PaymentDetails;
|
||||||
|
}) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [resetCount, setResetCount] = useState(0);
|
||||||
|
|
||||||
|
function handleDone() {
|
||||||
|
setIsEditing(false);
|
||||||
|
setResetCount((count) => count + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 w-full">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-semibold">Stammdaten</h1>
|
||||||
|
{!isEditing && (
|
||||||
|
<Button variant="outline" onClick={() => setIsEditing(true)}>
|
||||||
|
<Pen className="w-4 h-4 mr-2" /> Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StammdatenForm
|
||||||
|
key={`${resetCount}-${JSON.stringify({ client, paymentDetails })}`}
|
||||||
|
profileId={profileId}
|
||||||
|
client={client}
|
||||||
|
paymentDetails={paymentDetails}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onDone={handleDone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
modules/shared/ui/components/terms-dialog.tsx
Normal file
58
modules/shared/ui/components/terms-dialog.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { acceptTerms } from "../../actions/acceptTerms";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|
||||||
|
export function TermsDialog() {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleAccept() {
|
||||||
|
setPending(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await acceptTerms();
|
||||||
|
|
||||||
|
if (result && "success" in result) {
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setError(result?.error ?? "Zustimmung konnte nicht gespeichert werden.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Zustimmung konnte nicht gespeichert werden. Bitte erneut versuchen.");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={() => {}}>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nutzungsbedingungen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Bitte lies unsere{" "}
|
||||||
|
<a href="/terms" target="_blank" rel="noopener noreferrer">
|
||||||
|
Nutzungsbedingungen
|
||||||
|
</a>{" "}
|
||||||
|
und akzeptiere sie, um fortzufahren.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleAccept} disabled={pending}>
|
||||||
|
{pending ? "..." : "Akzeptieren"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
1288
package-lock.json
generated
1288
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue