mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
added stammdaten form
This commit is contained in:
parent
378d9b9546
commit
a9fdcf9fe5
18 changed files with 1249 additions and 6 deletions
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,6 +2,7 @@ import { cookies } from "next/headers";
|
|||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { verifySession } from "@/lib/supabase/dal";
|
||||
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 }) {
|
||||
|
|
@ -9,6 +10,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||
const cookieStore = await cookies()
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
|
||||
|
||||
// Check if User has accepted the Terms of Services before doing anything
|
||||
{!session.isAdmin && !session.hasAcceptedTerms && <TermsDialog/>}
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar session={session}/>
|
||||
|
|
|
|||
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 { verifySession } from "@/lib/supabase/dal";
|
||||
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 }) {
|
||||
|
|
@ -11,6 +12,7 @@ export default async function ClientLayout({ children }: { children: React.React
|
|||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
{!session.isAdmin && !session.hasAcceptedTerms && <TermsDialog/>}
|
||||
<AppSidebar session={session}/>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center border-b border-border p-3 md:hidden">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const getProfile = cache(async () => {
|
|||
const supabase = await createClient()
|
||||
const { data } = await supabase
|
||||
.from("profiles")
|
||||
.select("role, name, email")
|
||||
.select("role, name, email, terms_accepted_at")
|
||||
.eq("id", user.id)
|
||||
.single()
|
||||
|
||||
|
|
@ -41,5 +41,6 @@ export const verifySession = cache(async () => {
|
|||
isLoggedIn: true,
|
||||
isAdmin: profile.role === "admin",
|
||||
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;
|
||||
|
|
@ -52,7 +52,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
|
|||
return (
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<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
|
||||
placeholder="Search..."
|
||||
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}
|
||||
height={332}
|
||||
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">
|
||||
<h1 className="mb-6 text-3xl font-semibold">Login</h1>
|
||||
<form action={formAction} className="w-full max-w-sm ">
|
||||
|
|
@ -30,7 +36,7 @@ const LoginPageView = () => {
|
|||
id="form-email"
|
||||
name="email"
|
||||
type="text"
|
||||
className="bg-white focus-visible:ring-0"
|
||||
className="focus-visible:ring-0"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -41,7 +47,7 @@ const LoginPageView = () => {
|
|||
id="form-password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="bg-white focus-visible:ring-0 pr-10"
|
||||
className="focus-visible:ring-0 pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
|
|
@ -67,7 +73,7 @@ const LoginPageView = () => {
|
|||
</form>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
|
|
|
|||
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
|
||||
}
|
||||
}
|
||||
178
modules/shared/actions/stammdaten.ts
Normal file
178
modules/shared/actions/stammdaten.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"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 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(),
|
||||
});
|
||||
|
||||
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: newClient, error } = await supabase
|
||||
.from("clients")
|
||||
.insert(clientPayload)
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (error || !newClient) return { error: "Stammdaten konnten nicht gespeichert werden." };
|
||||
|
||||
clientId = newClient.id;
|
||||
|
||||
const { error: linkError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ client_id: clientId })
|
||||
.eq("id", data.profileId);
|
||||
|
||||
if (linkError) return { error: "Verknüpfung mit Profil fehlgeschlagen." };
|
||||
}
|
||||
|
||||
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.",
|
||||
];
|
||||
25
modules/shared/queries/stammdaten.ts
Normal file
25
modules/shared/queries/stammdaten.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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 [{ data: client }, { data: paymentDetails }] = await Promise.all([
|
||||
supabase.from("clients").select("*").eq("id", profile.client_id).single(),
|
||||
supabase.from("client_payment_details").select("*").eq("client_id", profile.client_id).single(),
|
||||
])
|
||||
|
||||
return { client: client ?? null, paymentDetails: paymentDetails ?? null }
|
||||
}
|
||||
42
modules/shared/ui/components/stammdaten-panel.tsx
Normal file
42
modules/shared/ui/components/stammdaten-panel.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"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);
|
||||
|
||||
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={JSON.stringify({ client, paymentDetails })}
|
||||
profileId={profileId}
|
||||
client={client}
|
||||
paymentDetails={paymentDetails}
|
||||
isEditing={isEditing}
|
||||
onDone={() => setIsEditing(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
modules/shared/ui/components/terms-dialog.tsx
Normal file
43
modules/shared/ui/components/terms-dialog.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"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 router = useRouter();
|
||||
|
||||
async function handleAccept() {
|
||||
setPending(true);
|
||||
const result = await acceptTerms();
|
||||
|
||||
if (result && "success" in result) {
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nutzungsbedingungen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bitte akzeptiere unsere Nutzungsbedingungen, um fortzufahren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleAccept} disabled={pending}>
|
||||
{pending ? "..." : "Akzeptieren"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue