Compare commits

...

13 Commits

Author SHA1 Message Date
52c182cb5f Fixes
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 3m8s
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
2026-03-04 20:44:19 +01:00
9cef3964e9 Serienrechnungen ausführung sowie Anwahl und liste 2026-03-04 19:54:12 +01:00
cf0fb724a2 Fix #126 2026-02-22 19:33:56 +01:00
bbb893dd6c Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-02-21 22:41:23 +01:00
724f152d70 Fix #116 2026-02-21 22:41:07 +01:00
27be8241bf Initial for #123 2026-02-21 22:23:32 +01:00
d27e437ba6 Fix Error in IcomingInvoice Opening of Drafts 2026-02-21 22:23:32 +01:00
f5253b29f4 Fix #113 2026-02-21 22:23:31 +01:00
0141a243ce Initial for #123
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-02-21 22:21:10 +01:00
a0e1b8c0eb Fix Error in IcomingInvoice Opening of Drafts 2026-02-21 22:19:45 +01:00
45fb45845a Fix #116 2026-02-21 22:17:58 +01:00
409db82368 Mobile Dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m50s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
2026-02-21 21:21:39 +01:00
30d761f899 fix memberrlation 2026-02-21 21:21:27 +01:00
45 changed files with 3927 additions and 1367 deletions

View File

@@ -0,0 +1,33 @@
ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "memberrelation" bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'customers_memberrelation_memberrelations_id_fk'
) THEN
ALTER TABLE "customers"
ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk"
FOREIGN KEY ("memberrelation")
REFERENCES "public"."memberrelations"("id")
ON DELETE no action
ON UPDATE no action;
END IF;
END $$;
UPDATE "customers"
SET "memberrelation" = ("infoData"->>'memberrelation')::bigint
WHERE
"memberrelation" IS NULL
AND "type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation'
AND ("infoData"->>'memberrelation') ~ '^[0-9]+$';
UPDATE "customers"
SET "infoData" = COALESCE("infoData", '{}'::jsonb) - 'memberrelation'
WHERE
"type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation';

View File

@@ -0,0 +1,108 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"billingInterval" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerinventoryitems" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"customerspace" bigint,
"customerInventoryId" text NOT NULL,
"serialNumber" text,
"quantity" bigint DEFAULT 0 NOT NULL,
"manufacturer" text,
"manufacturerNumber" text,
"purchaseDate" date,
"purchasePrice" double precision DEFAULT 0,
"currentValue" double precision,
"product" bigint,
"vendor" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerspaces" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"spaceNumber" text NOT NULL,
"parentSpace" bigint,
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "entitybankaccounts" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"iban_encrypted" jsonb NOT NULL,
"bic_encrypted" jsonb NOT NULL,
"bank_name_encrypted" jsonb NOT NULL,
"description" text,
"updated_at" timestamp with time zone,
"updated_by" uuid,
"archived" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberrelations" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"type" text NOT NULL,
"billingInterval" text NOT NULL,
"billingAmount" double precision DEFAULT 0 NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "accounts" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "createddocuments"
ALTER COLUMN "customSurchargePercentage" TYPE double precision
USING "customSurchargePercentage"::double precision;

View File

@@ -113,6 +113,27 @@
"when": 1773000700000,
"tag": "0015_wise_memberrelation_history",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1773000800000,
"tag": "0016_fix_memberrelation_column_usage",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1771704862789,
"tag": "0017_slow_the_hood",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773000900000,
"tag": "0018_account_chart",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,7 @@ export const accounts = pgTable("accounts", {
number: text("number").notNull(),
label: text("label").notNull(),
accountChart: text("accountChart").notNull().default("skr03"),
description: text("description"),
})

View File

@@ -6,6 +6,7 @@ import {
jsonb,
boolean,
smallint,
doublePrecision,
uuid,
} from "drizzle-orm/pg-core"
@@ -96,7 +97,7 @@ export const createddocuments = pgTable("createddocuments", {
taxType: text("taxType"),
customSurchargePercentage: smallint("customSurchargePercentage")
customSurchargePercentage: doublePrecision("customSurchargePercentage")
.notNull()
.default(0),

View File

@@ -10,6 +10,7 @@ import {
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { memberrelations } from "./memberrelations"
export const customers = pgTable(
"customers",
@@ -63,6 +64,7 @@ export const customers = pgTable(
customPaymentType: text("custom_payment_type"), // ENUM payment_types separat?
customTaxType: text("customTaxType"),
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
}
)

View File

@@ -74,6 +74,48 @@ export const tenants = pgTable(
timeTracking: true,
planningBoard: true,
workingTimeTracking: true,
dashboard: true,
historyitems: true,
tasks: true,
wiki: true,
files: true,
createdletters: true,
documentboxes: true,
helpdesk: true,
email: true,
members: true,
customers: true,
vendors: true,
contactsList: true,
staffTime: true,
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
costcentres: true,
accounts: true,
ownaccounts: true,
banking: true,
spaces: true,
customerspaces: true,
customerinventoryitems: true,
inventoryitems: true,
inventoryitemgroups: true,
products: true,
productcategories: true,
services: true,
servicecategories: true,
memberrelations: true,
staffProfiles: true,
hourrates: true,
projecttypes: true,
contracttypes: true,
plants: true,
settingsNumberRanges: true,
settingsEmailAccounts: true,
settingsBanking: true,
settingsTexttemplates: true,
settingsTenant: true,
export: true,
}),
ownFields: jsonb("ownFields"),
@@ -94,6 +136,7 @@ export const tenants = pgTable(
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
}),
accountChart: text("accountChart").notNull().default("skr03"),
standardEmailForInvoices: text("standardEmailForInvoices"),

View File

@@ -10,7 +10,9 @@
"build": "tsc",
"start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts"
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
},
"repository": {
"type": "git",

View File

@@ -38,6 +38,11 @@ function normalizeUuid(value: unknown): string | null {
return trimmed.length ? trimmed : null;
}
function sanitizeCompositionRows(value: unknown): CompositionRow[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is CompositionRow => !!entry && typeof entry === "object");
}
export async function recalculateServicePricesForTenant(server: FastifyInstance, tenantId: number, updatedBy?: string | null) {
const [services, products, hourrates] = await Promise.all([
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)),
@@ -88,94 +93,111 @@ export async function recalculateServicePricesForTenant(server: FastifyInstance,
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
materialComposition: Array.isArray(service.materialComposition) ? service.materialComposition as CompositionRow[] : [],
personalComposition: Array.isArray(service.personalComposition) ? service.personalComposition as CompositionRow[] : [],
materialComposition: sanitizeCompositionRows(service.materialComposition),
personalComposition: sanitizeCompositionRows(service.personalComposition),
};
memo.set(serviceId, lockedResult);
return lockedResult;
}
stack.add(serviceId);
try {
const materialComposition = sanitizeCompositionRows(service.materialComposition);
const personalComposition = sanitizeCompositionRows(service.personalComposition);
const hasMaterialComposition = materialComposition.length > 0;
const hasPersonalComposition = personalComposition.length > 0;
const materialComposition: CompositionRow[] = Array.isArray(service.materialComposition)
? (service.materialComposition as CompositionRow[])
: [];
const personalComposition: CompositionRow[] = Array.isArray(service.personalComposition)
? (service.personalComposition as CompositionRow[])
: [];
let materialTotal = 0;
let materialPurchaseTotal = 0;
const normalizedMaterialComposition = materialComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const productId = normalizeId(entry.product);
const childServiceId = normalizeId(entry.service);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (productId) {
const product = productMap.get(productId);
sellingPrice = toNumber(product?.selling_price);
purchasePrice = toNumber(product?.purchase_price);
} else if (childServiceId) {
const child = calculateService(childServiceId);
sellingPrice = toNumber(child.sellingTotal);
purchasePrice = toNumber(child.purchaseTotal);
// Ohne Zusammensetzung keine automatische Überschreibung:
// manuell gepflegte Preise sollen erhalten bleiben.
if (!hasMaterialComposition && !hasPersonalComposition) {
const manualResult = {
sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
materialComposition,
personalComposition,
};
memo.set(serviceId, manualResult);
return manualResult;
}
materialTotal += quantity * sellingPrice;
materialPurchaseTotal += quantity * purchasePrice;
let materialTotal = 0;
let materialPurchaseTotal = 0;
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
};
});
const normalizedMaterialComposition = materialComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const productId = normalizeId(entry.product);
const childServiceId = normalizeId(entry.service);
let workerTotal = 0;
let workerPurchaseTotal = 0;
const normalizedPersonalComposition = personalComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const hourrateId = normalizeUuid(entry.hourrate);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (hourrateId) {
const hourrate = hourrateMap.get(hourrateId);
if (hourrate) {
sellingPrice = toNumber(hourrate.sellingPrice);
purchasePrice = toNumber(hourrate.purchase_price);
if (productId) {
const product = productMap.get(productId);
sellingPrice = toNumber(product?.selling_price);
purchasePrice = toNumber(product?.purchase_price);
} else if (childServiceId) {
const child = calculateService(childServiceId);
sellingPrice = toNumber(child.sellingTotal);
purchasePrice = toNumber(child.purchaseTotal);
}
}
workerTotal += quantity * sellingPrice;
workerPurchaseTotal += quantity * purchasePrice;
materialTotal += quantity * sellingPrice;
materialPurchaseTotal += quantity * purchasePrice;
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
};
});
let workerTotal = 0;
let workerPurchaseTotal = 0;
const normalizedPersonalComposition = personalComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const hourrateId = normalizeUuid(entry.hourrate);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (hourrateId) {
const hourrate = hourrateMap.get(hourrateId);
if (hourrate) {
sellingPrice = toNumber(hourrate.sellingPrice);
purchasePrice = toNumber(hourrate.purchase_price);
}
}
workerTotal += quantity * sellingPrice;
workerPurchaseTotal += quantity * purchasePrice;
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
};
});
const result = {
sellingTotal: round2(materialTotal + workerTotal),
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal),
materialTotal: round2(materialTotal),
materialPurchaseTotal: round2(materialPurchaseTotal),
workerTotal: round2(workerTotal),
workerPurchaseTotal: round2(workerPurchaseTotal),
materialComposition: normalizedMaterialComposition,
personalComposition: normalizedPersonalComposition,
};
});
const result = {
sellingTotal: round2(materialTotal + workerTotal),
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal),
materialTotal: round2(materialTotal),
materialPurchaseTotal: round2(materialPurchaseTotal),
workerTotal: round2(workerTotal),
workerPurchaseTotal: round2(workerPurchaseTotal),
materialComposition: normalizedMaterialComposition,
personalComposition: normalizedPersonalComposition,
};
memo.set(serviceId, result);
stack.delete(serviceId);
return result;
memo.set(serviceId, result);
return result;
} finally {
stack.delete(serviceId);
}
};
for (const service of services) {

View File

@@ -58,8 +58,6 @@ const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
const query = req.query as Record<string, any>
console.log(query)
// Pagination deaktivieren?
const disablePagination =
query.noPagination === 'true' ||

View File

@@ -94,6 +94,7 @@ export default async function adminRoutes(server: FastifyInstance) {
short: tenants.short,
locked: tenants.locked,
numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
extraModules: tenants.extraModules,
})
.from(authTenantUsers)

View File

@@ -51,9 +51,11 @@ export default async function meRoutes(server: FastifyInstance) {
name: tenants.name,
short: tenants.short,
locked: tenants.locked,
features: tenants.features,
extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo,
numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
dokuboxkey: tenants.dokuboxkey,
standardEmailForInvoices: tenants.standardEmailForInvoices,
standardPaymentDays: tenants.standardPaymentDays,

View File

@@ -59,6 +59,44 @@ const parseId = (value: string) => {
}
export default async function resourceHistoryRoutes(server: FastifyInstance) {
server.get("/history", {
schema: {
tags: ["History"],
summary: "Get all history entries for the active tenant",
},
}, async (req: any) => {
const data = await server.db
.select()
.from(historyitems)
.where(eq(historyitems.tenant, req.user?.tenant_id))
.orderBy(asc(historyitems.createdAt));
const userIds = Array.from(
new Set(data.map((item) => item.createdBy).filter(Boolean))
) as string[];
const profiles = userIds.length > 0
? await server.db
.select()
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, req.user?.tenant_id),
inArray(authProfiles.user_id, userIds)
))
: [];
const profileByUserId = new Map(
profiles.map((profile) => [profile.user_id, profile])
);
return data.map((historyitem) => ({
...historyitem,
created_at: historyitem.createdAt,
created_by: historyitem.createdBy,
created_by_profile: historyitem.createdBy ? profileByUserId.get(historyitem.createdBy) || null : null,
}));
});
server.get<{
Params: { resource: string; id: string }
}>("/resource/:resource/:id/history", {

View File

@@ -586,6 +586,9 @@ export default async function resourceRoutes(server: FastifyInstance) {
try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
const body = req.body as Record<string, any>;
const config = resourceConfig[resource];
const table = config.table;
@@ -656,6 +659,9 @@ export default async function resourceRoutes(server: FastifyInstance) {
server.put("/resource/:resource/:id", async (req, reply) => {
try {
const { resource, id } = req.params as { resource: string; id: string }
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id

View File

@@ -1,9 +1,9 @@
import { FastifyInstance } from "fastify"
import { asc, desc } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { sortData } from "../utils/sort"
// Schema imports
import { accounts, units,countrys } from "../../db/schema"
import { accounts, units, countrys, tenants } from "../../db/schema"
const TABLE_MAP: Record<string, any> = {
accounts,
@@ -40,6 +40,44 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
// ---------------------------------------
if (resource === "accounts") {
const [tenant] = await server.db
.select({
accountChart: tenants.accountChart,
})
.from(tenants)
.where(eq(tenants.id, Number(req.user.tenant_id)))
.limit(1)
const activeAccountChart = tenant?.accountChart || "skr03"
let data
if (sort && (accounts as any)[sort]) {
const col = (accounts as any)[sort]
data = ascQuery === "true"
? await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
.orderBy(asc(col))
: await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
.orderBy(desc(col))
} else {
data = await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
}
return sortData(
data,
sort as any,
ascQuery === "true"
)
}
let query = server.db.select().from(table)
// ---------------------------------------

View File

@@ -11,7 +11,7 @@ import { s3 } from "./s3";
import { secrets } from "./secrets";
// Drizzle schema
import { vendors, accounts } from "../../db/schema";
import { vendors, accounts, tenants } from "../../db/schema";
import {eq} from "drizzle-orm";
let openai: OpenAI | null = null;
@@ -163,13 +163,22 @@ export const getInvoiceDataFromGPT = async function (
.from(vendors)
.where(eq(vendors.tenant,tenantId));
const [tenant] = await server.db
.select({ accountChart: tenants.accountChart })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
const activeAccountChart = tenant?.accountChart || "skr03"
const accountList = await server.db
.select({
id: accounts.id,
label: accounts.label,
number: accounts.number,
})
.from(accounts);
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart));
// ---------------------------------------------------------
// 4) GPT ANALYSIS

View File

@@ -3,11 +3,13 @@ import dayjs from "dayjs"
const props = defineProps({
type: {
type: String,
required: true
required: false,
default: null
},
elementId: {
type: String,
required: true
required: false,
default: null
},
renderHeadline: {
type: Boolean,
@@ -25,13 +27,11 @@ const items = ref([])
const platform = ref("default")
const setup = async () => {
if(props.type && props.elementId){
items.value = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`)
} /*else {
}*/
} else {
items.value = await useNuxtApp().$api(`/api/history`)
}
}
setup()
@@ -43,6 +43,10 @@ const addHistoryItemData = ref({
})
const addHistoryItem = async () => {
if (!props.type || !props.elementId) {
toast.add({ title: "Im zentralen Logbuch können keine direkten Einträge erstellt werden." })
return
}
const res = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`, {
method: "POST",
@@ -161,4 +165,4 @@ const renderText = (text) => {
<style scoped>
</style>
</style>

View File

@@ -15,8 +15,241 @@ const showMembersNav = computed(() => {
const showMemberRelationsNav = computed(() => {
return tenantExtraModules.value.includes("verein") && has("members")
})
const tenantFeatures = computed(() => auth.activeTenantData?.features || {})
const featureEnabled = (key) => tenantFeatures.value?.[key] !== false
const links = computed(() => {
const organisationChildren = [
has("tasks") && featureEnabled("tasks") ? {
label: "Aufgaben",
to: "/tasks",
icon: "i-heroicons-rectangle-stack"
} : null,
featureEnabled("wiki") ? {
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
} : null,
].filter(Boolean)
const documentChildren = [
featureEnabled("files") ? {
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
} : null,
featureEnabled("createdletters") ? {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
} : null,
featureEnabled("documentboxes") ? {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
} : null,
].filter(Boolean)
const communicationChildren = [
featureEnabled("helpdesk") ? {
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
} : null,
featureEnabled("email") ? {
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
} : null,
].filter(Boolean)
const contactsChildren = [
showMembersNav.value && featureEnabled("members") ? {
label: "Mitglieder",
to: "/standardEntity/members",
icon: "i-heroicons-user-group"
} : null,
has("customers") && featureEnabled("customers") ? {
label: "Kunden",
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
} : null,
has("vendors") && featureEnabled("vendors") ? {
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
} : null,
has("contacts") && featureEnabled("contactsList") ? {
label: "Ansprechpartner",
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
} : null,
].filter(Boolean)
const staffChildren = [
featureEnabled("staffTime") ? {
label: "Zeiten",
to: "/staff/time",
icon: "i-heroicons-clock",
} : null,
].filter(Boolean)
const accountingChildren = [
featureEnabled("createDocument") ? {
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("serialInvoice") ? {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("incomingInvoices") ? {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("costcentres") ? {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
} : null,
featureEnabled("accounts") ? {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("ownaccounts") ? {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("banking") ? {
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
} : null,
].filter(Boolean)
const inventoryChildren = [
has("spaces") && featureEnabled("spaces") ? {
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
} : null,
has("inventoryitems") && featureEnabled("customerspaces") ? {
label: "Kundenlagerplätze",
to: "/standardEntity/customerspaces",
icon: "i-heroicons-squares-plus"
} : null,
has("inventoryitems") && featureEnabled("customerinventoryitems") ? {
label: "Kundeninventar",
to: "/standardEntity/customerinventoryitems",
icon: "i-heroicons-qr-code"
} : null,
has("inventoryitems") && featureEnabled("inventoryitems") ? {
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
} : null,
has("inventoryitems") && featureEnabled("inventoryitemgroups") ? {
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
} : null,
].filter(Boolean)
const masterDataChildren = [
has("products") && featureEnabled("products") ? {
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
} : null,
has("productcategories") && featureEnabled("productcategories") ? {
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
} : null,
has("services") && featureEnabled("services") ? {
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
} : null,
has("servicecategories") && featureEnabled("servicecategories") ? {
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
} : null,
showMemberRelationsNav.value && featureEnabled("memberrelations") ? {
label: "Mitgliedsverhältnisse",
to: "/standardEntity/memberrelations",
icon: "i-heroicons-identification"
} : null,
featureEnabled("staffProfiles") ? {
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("hourrates") ? {
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("projecttypes") ? {
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("contracttypes") ? {
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
} : null,
has("vehicles") && featureEnabled("vehicles") ? {
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
} : null,
].filter(Boolean)
const settingsChildren = [
featureEnabled("settingsNumberRanges") ? {
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsEmailAccounts") ? {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
} : null,
featureEnabled("settingsBanking") ? {
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
} : null,
featureEnabled("settingsTexttemplates") ? {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsTenant") ? {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
} : null,
featureEnabled("export") ? {
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
} : null,
].filter(Boolean)
return [
...(auth.profile?.pinned_on_navigation || []).map(pin => {
if (pin.type === "external") {
@@ -37,291 +270,89 @@ const links = computed(() => {
}
}),
{
featureEnabled("dashboard") ? {
id: 'dashboard',
label: "Dashboard",
to: "/",
icon: "i-heroicons-home"
},
{
} : null,
featureEnabled("historyitems") ? {
id: 'historyitems',
label: "Logbuch",
to: "/historyitems",
icon: "i-heroicons-book-open",
disabled: true
},
{
icon: "i-heroicons-book-open"
} : null,
...(organisationChildren.length > 0 ? [{
label: "Organisation",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
...has("tasks") ? [{
label: "Aufgaben",
to: "/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
...true ? [{
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
}] : [],
]
},
{
children: organisationChildren
}] : []),
...(documentChildren.length > 0 ? [{
label: "Dokumente",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
{
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
}, {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
}, {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
},
]
},
{
children: documentChildren
}] : []),
...(communicationChildren.length > 0 ? [{
label: "Kommunikation",
icon: "i-heroicons-megaphone",
defaultOpen: false,
children: [
{
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
},
{
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
}
]
},
...(has("customers") || has("vendors") || has("contacts") || showMembersNav.value) ? [{
children: communicationChildren
}] : []),
...(contactsChildren.length > 0 ? [{
label: "Kontakte",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
...showMembersNav.value ? [{
label: "Mitglieder",
to: "/standardEntity/members",
icon: "i-heroicons-user-group"
}] : [],
...has("customers") ? [{
label: "Kunden",
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
}] : [],
...has("vendors") ? [{
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
}] : [],
...has("contacts") ? [{
label: "Ansprechpartner",
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
}] : [],
]
}] : [],
{
children: contactsChildren
}] : []),
...(staffChildren.length > 0 ? [{
label: "Mitarbeiter",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
...true ? [{
label: "Zeiten",
to: "/staff/time",
icon: "i-heroicons-clock",
}] : [],
]
},
...[{
children: staffChildren
}] : []),
...(accountingChildren.length > 0 ? [{
label: "Buchhaltung",
defaultOpen: false,
icon: "i-heroicons-chart-bar-square",
children: [
{
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
}, {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
}, {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
}, {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
}, {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
}, {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
},
{
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
},
]
}],
...has("inventory") ? [{
children: accountingChildren
}] : []),
...(inventoryChildren.length > 0 ? [{
label: "Lager",
icon: "i-heroicons-puzzle-piece",
defaultOpen: false,
children: [
...has("spaces") ? [{
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
}] : [],
...has("inventoryitems") ? [{
label: "Kundenlagerplätze",
to: "/standardEntity/customerspaces",
icon: "i-heroicons-squares-plus"
}] : [],
...has("inventoryitems") ? [{
label: "Kundeninventar",
to: "/standardEntity/customerinventoryitems",
icon: "i-heroicons-qr-code"
}] : [],
...has("inventoryitems") ? [{
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("inventoryitems") ? [{
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
}] : [],
]
}] : [],
{
children: inventoryChildren
}] : []),
...(masterDataChildren.length > 0 ? [{
label: "Stammdaten",
defaultOpen: false,
icon: "i-heroicons-clipboard-document",
children: [
...has("products") ? [{
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("productcategories") ? [{
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("services") ? [{
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
...has("servicecategories") ? [{
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
...showMemberRelationsNav.value ? [{
label: "Mitgliedsverhältnisse",
to: "/standardEntity/memberrelations",
icon: "i-heroicons-identification"
}] : [],
{
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
},
{
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
},
{
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
},
{
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
},
...has("vehicles") ? [{
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
}] : [],
]
},
children: masterDataChildren
}] : []),
...has("projects") ? [{
...(has("projects") && featureEnabled("projects")) ? [{
label: "Projekte",
to: "/standardEntity/projects",
icon: "i-heroicons-clipboard-document-check"
}] : [],
...has("contracts") ? [{
...(has("contracts") && featureEnabled("contracts")) ? [{
label: "Verträge",
to: "/standardEntity/contracts",
icon: "i-heroicons-clipboard-document"
}] : [],
...has("plants") ? [{
...(has("plants") && featureEnabled("plants")) ? [{
label: "Objekte",
to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document"
}] : [],
{
...(settingsChildren.length > 0 ? [{
label: "Einstellungen",
defaultOpen: false,
icon: "i-heroicons-cog-8-tooth",
children: [
{
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
}, {
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
}, {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
}, {
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
}
]
},
]
children: settingsChildren
}] : []),
].filter(Boolean)
})
const accordionItems = computed(() =>

View File

@@ -6,8 +6,20 @@ const props = defineProps({
default: {}
}
})
const descriptionText = computed(() => {
const description = props.row?.description
if (!description) return ""
if (typeof description === "string") return description
if (typeof description === "object") {
if (typeof description.text === "string" && description.text.trim().length) {
return description.text
}
}
return String(description)
})
</script>
<template>
<div v-if="props.row.description" v-html="props.row.description.html"/>
<div v-if="descriptionText">{{ descriptionText }}</div>
</template>

View File

@@ -11,18 +11,22 @@ const relations = ref([])
const normalizeId = (value) => {
if (value === null || value === undefined || value === "") return null
if (typeof value === "object") return normalizeId(value.id)
const parsed = Number(value)
return Number.isNaN(parsed) ? String(value) : parsed
}
const relationLabel = computed(() => {
const id = normalizeId(props.row?.infoData?.memberrelation)
const relation = props.row?.memberrelation
if (relation && typeof relation === "object" && relation.type) return relation.type
const id = normalizeId(relation)
if (!id) return ""
return relations.value.find((i) => normalizeId(i.id) === id)?.type || ""
})
const relationId = computed(() => {
return normalizeId(props.row?.infoData?.memberrelation)
return normalizeId(props.row?.memberrelation)
})
const loadRelations = async () => {

View File

@@ -1,239 +1,279 @@
<script setup>
import customParseFormat from "dayjs/plugin/customParseFormat";
import dayjs from "dayjs";
import { Line } from "vue-chartjs";
dayjs.extend(customParseFormat)
const tempStore = useTempStore()
let incomeData = ref({})
let expenseData = ref({})
const amountMode = ref("net")
const granularity = ref("year")
const selectedYear = ref(dayjs().year())
const selectedMonth = ref(dayjs().month() + 1)
const setup = async () => {
let incomeRawData = (await useEntities("createddocuments").select()).filter(i => i.state === "Gebucht" && ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type))
const incomeDocuments = ref([])
const expenseInvoices = ref([])
let incomeRawFilteredData = incomeRawData.filter(x => x.state === 'Gebucht' && incomeRawData.find(i => i.linkedDocument && i.linkedDocument.id === x.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type))
const granularityOptions = [
{ label: "Jahr", value: "year" },
{ label: "Monat", value: "month" }
]
const monthOptions = [
{ label: "Januar", value: 1 },
{ label: "Februar", value: 2 },
{ label: "März", value: 3 },
{ label: "April", value: 4 },
{ label: "Mai", value: 5 },
{ label: "Juni", value: 6 },
{ label: "Juli", value: 7 },
{ label: "August", value: 8 },
{ label: "September", value: 9 },
{ label: "Oktober", value: 10 },
{ label: "November", value: 11 },
{ label: "Dezember", value: 12 }
]
let expenseRawData =(await useEntities("incominginvoices").select())
let withoutInvoiceRawData = (await useEntities("statementallocations").select()).filter(i => i.account)
const normalizeMode = (value) => value === "gross" ? "gross" : "net"
const normalizeGranularity = (value) => value === "month" ? "month" : "year"
let withoutInvoiceRawDataExpenses = []
let withoutInvoiceRawDataIncomes = []
watch(
() => tempStore.settings?.dashboardIncomeExpenseView,
(storedView) => {
const legacyMode = tempStore.settings?.dashboardIncomeExpenseMode
withoutInvoiceRawData.forEach(i => {
if(i.amount > 0) {
withoutInvoiceRawDataIncomes.push({
id: i.id,
date: dayjs(i.created_at).format("DD-MM-YY"),
amount: Math.abs(i.amount),
bs_id: i.bs_id
})
} else if(i.amount < 0) {
withoutInvoiceRawDataExpenses.push({
id: i.id,
date: dayjs(i.created_at).format("DD-MM-YY"),
amount: Math.abs(i.amount),
bs_id: i.bs_id
})
}
amountMode.value = normalizeMode(storedView?.amountMode || legacyMode)
granularity.value = normalizeGranularity(storedView?.granularity)
const nextYear = Number(storedView?.year)
const nextMonth = Number(storedView?.month)
selectedYear.value = Number.isFinite(nextYear) ? nextYear : dayjs().year()
selectedMonth.value = Number.isFinite(nextMonth) && nextMonth >= 1 && nextMonth <= 12
? nextMonth
: dayjs().month() + 1
},
{ immediate: true }
)
watch([amountMode, granularity, selectedYear, selectedMonth], () => {
tempStore.modifySettings("dashboardIncomeExpenseView", {
amountMode: amountMode.value,
granularity: granularity.value,
year: selectedYear.value,
month: selectedMonth.value
})
/*withoutInvoiceRawDataExpenses.forEach(i => {
expenseData.value[i.date] ? expenseData.value[i.date] = Number((expenseData.value[i.date] + i.amount).toFixed(2)) : expenseData.value[i.date] = i.amount
})
withoutInvoiceRawDataIncomes.forEach(i => {
incomeData.value[i.date] ? incomeData.value[i.date] = Number((incomeData.value[i.date] + i.amount).toFixed(2)) : incomeData.value[i.date] = i.amount
})*/
expenseRawData = expenseRawData.filter(i => i.date).map(i => {
let amount = 0
i.accounts.forEach(a => {
amount += a.amountNet
})
amount = Number(amount.toFixed(2))
return {
id: i.id,
date: dayjs(i.date).format("DD-MM-YY"),
amount
}
})
expenseRawData.forEach(i => {
expenseData.value[i.date] ? expenseData.value[i.date] = Number((expenseData.value[i.date] + i.amount).toFixed(2)) : expenseData.value[i.date] = i.amount
})
let expenseMonths = {
"01": 0,
"02": 0,
"03": 0,
"04": 0,
"05": 0,
"06": 0,
"07": 0,
"08": 0,
"09": 0,
"10": 0,
"11": 0,
"12": 0,
}
Object.keys(expenseMonths).forEach(month => {
let dates = Object.keys(expenseData.value).filter(i => i.split("-")[1] === month && i.split("-")[2] === dayjs().format("YY"))
dates.forEach(date => {
if(expenseMonths[month]){
expenseMonths[month] = Number((expenseMonths[month] + expenseData.value[date]).toFixed(2))
} else {
expenseMonths[month] = expenseData.value[date]
}
})
})
expenseData.value = expenseMonths
incomeRawData = incomeRawData.map(i => {
let amount = 0
i.rows.forEach(r => {
if(r.mode !== "pagebreak" && r.mode !== "title" && r.mode !== "text"){
amount += r.price * r.quantity * (1 - r.discountPercent/100)
}
})
amount = Number(amount.toFixed(2))
return {
id: i.id,
date: dayjs(i.documentDate).format("DD-MM-YY"),
amount
}
})
incomeRawData.forEach(i => {
incomeData.value[i.date] ? incomeData.value[i.date] = Number((incomeData.value[i.date] + i.amount).toFixed(2)) : incomeData.value[i.date] = i.amount
})
let incomeMonths = {
"01": 0,
"02": 0,
"03": 0,
"04": 0,
"05": 0,
"06": 0,
"07": 0,
"08": 0,
"09": 0,
"10": 0,
"11": 0,
"12": 0,
}
Object.keys(incomeMonths).forEach(month => {
let dates = Object.keys(incomeData.value).filter(i => i.split("-")[1] === month && i.split("-")[2] === dayjs().format("YY"))
dates.forEach(date => {
if(incomeMonths[month]){
incomeMonths[month] = Number((incomeMonths[month] + incomeData.value[date]).toFixed(2))
} else {
incomeMonths[month] = incomeData.value[date]
}
})
})
incomeData.value = incomeMonths
}
const days = computed(() => {
let days = []
days = Object.keys(incomeData.value)
let expenseDays = Object.keys(expenseData.value)
expenseDays.forEach(expenseDay => {
if(!days.find(i => i === expenseDay)){
days.push(expenseDay)
}
})
days = days.sort(function(a, b) {
var keyA = dayjs(a, "DD-MM-YY"),
keyB = dayjs(b, "DD-MM-YY");
// Compare the 2 dates
if (keyA.isBefore(keyB,'day')) {
return -1;
} else if(keyB.isBefore(keyA, 'day')) {
return 1
} else {
return 0;
}
});
return days
// Backward compatibility for any existing consumers.
tempStore.modifySettings("dashboardIncomeExpenseMode", amountMode.value)
})
/*const chartData = computed(() => {
return {
labels: days.value,
datasets: [
{
label: 'Einnahmen',
data: [2, 1, 16, 3, 2],
backgroundColor: 'rgba(20, 255, 0, 0.3)',
borderColor: 'red',
borderWidth: 2,
}
]
const loadData = async () => {
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
expenseInvoices.value = (incoming || []).filter((item) => item.date)
}
const yearsInData = computed(() => {
const years = new Set([dayjs().year()])
incomeDocuments.value.forEach((item) => {
const parsed = dayjs(item.documentDate)
if (parsed.isValid()) years.add(parsed.year())
})
expenseInvoices.value.forEach((item) => {
const parsed = dayjs(item.date)
if (parsed.isValid()) years.add(parsed.year())
})
return Array.from(years).sort((a, b) => b - a)
})
const yearOptions = computed(() => yearsInData.value.map((year) => ({ label: String(year), value: year })))
watch(yearsInData, (years) => {
if (!years.includes(selectedYear.value) && years.length > 0) {
selectedYear.value = years[0]
}
})*/
}, { immediate: true })
const computeDocumentAmount = (doc) => {
let amount = 0
;(doc.rows || []).forEach((row) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return
const net = Number(row.price || 0) * Number(row.quantity || 0) * (1 - Number(row.discountPercent || 0) / 100)
const taxPercent = Number(row.taxPercent)
const gross = net * (1 + (Number.isFinite(taxPercent) ? taxPercent : 0) / 100)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const computeIncomingInvoiceAmount = (invoice) => {
let amount = 0
;(invoice.accounts || []).forEach((account) => {
const net = Number(account.amountNet || 0)
const tax = Number(account.amountTax || 0)
const grossValue = Number(account.amountGross)
const gross = Number.isFinite(grossValue) ? grossValue : (net + tax)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const buckets = computed(() => {
const income = {}
const expense = {}
if (granularity.value === "year") {
for (let month = 1; month <= 12; month += 1) {
const key = String(month).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
} else {
const daysInMonth = dayjs(`${selectedYear.value}-${String(selectedMonth.value).padStart(2, "0")}-01`).daysInMonth()
for (let day = 1; day <= daysInMonth; day += 1) {
const key = String(day).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
}
incomeDocuments.value.forEach((doc) => {
const docDate = dayjs(doc.documentDate)
if (!docDate.isValid() || docDate.year() !== selectedYear.value) return
if (granularity.value === "month" && docDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(docDate.month() + 1).padStart(2, "0")
: String(docDate.date()).padStart(2, "0")
income[key] = Number((income[key] + computeDocumentAmount(doc)).toFixed(2))
})
expenseInvoices.value.forEach((invoice) => {
const invoiceDate = dayjs(invoice.date)
if (!invoiceDate.isValid() || invoiceDate.year() !== selectedYear.value) return
if (granularity.value === "month" && invoiceDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(invoiceDate.month() + 1).padStart(2, "0")
: String(invoiceDate.date()).padStart(2, "0")
expense[key] = Number((expense[key] + computeIncomingInvoiceAmount(invoice)).toFixed(2))
})
return { income, expense }
})
const chartLabels = computed(() => {
if (granularity.value === "year") {
return ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
}
return Object.keys(buckets.value.income).map((day) => `${day}.`)
})
import { Line } from 'vue-chartjs'
const chartData = computed(() => {
const keys = Object.keys(buckets.value.income).sort()
return {
labels: ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],
labels: chartLabels.value,
datasets: [
{
label: 'Ausgaben',
backgroundColor: '#f87979',
borderColor: '#f87979',
data: Object.keys(expenseData.value).sort().map(i => expenseData.value[i]),
label: "Ausgaben",
backgroundColor: "#f87979",
borderColor: "#f87979",
data: keys.map((key) => buckets.value.expense[key]),
tension: 0.3,
},{
label: 'Einnahmen',
backgroundColor: '#69c350',
borderColor: '#69c350',
data: Object.keys(incomeData.value).sort().map(i => incomeData.value[i]),
},
{
label: "Einnahmen",
backgroundColor: "#69c350",
borderColor: "#69c350",
data: keys.map((key) => buckets.value.income[key]),
tension: 0.3
},
],
}
})
const chartOptions = ref({
responsive: true,
maintainAspectRatio: false,
})
setup()
loadData()
</script>
<template>
<Line
:data="chartData"
:options="chartOptions"
/>
<div class="h-full flex flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<USelectMenu
v-model="granularity"
:options="granularityOptions"
value-attribute="value"
option-attribute="label"
class="w-28"
/>
<USelectMenu
v-model="selectedYear"
:options="yearOptions"
value-attribute="value"
option-attribute="label"
class="w-24"
/>
<USelectMenu
v-if="granularity === 'month'"
v-model="selectedMonth"
:options="monthOptions"
value-attribute="value"
option-attribute="label"
class="w-36"
/>
</div>
<UButtonGroup size="xs">
<UButton
:variant="amountMode === 'net' ? 'solid' : 'outline'"
@click="amountMode = 'net'"
>
Netto
</UButton>
<UButton
:variant="amountMode === 'gross' ? 'solid' : 'outline'"
@click="amountMode = 'gross'"
>
Brutto
</UButton>
</UButtonGroup>
</div>
<div class="flex-1 min-h-[280px]">
<Line
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template>
<style scoped>

View File

@@ -2073,6 +2073,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
>
<UInput
type="number"
step="0.01"
v-model="itemInfo.customSurchargePercentage"
@change="updateCustomSurcharge"
>

View File

@@ -158,13 +158,24 @@
<UDivider label="Vorlagen auswählen" />
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<UInput
v-model="modalSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Kunde oder Vertrag suchen..."
class="w-full sm:w-64"
size="sm"
/>
<div class="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<UInput
v-model="modalSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Kunde oder Vertrag suchen..."
class="w-full sm:w-64"
size="sm"
/>
<USelectMenu
v-model="selectedExecutionIntervall"
:options="executionIntervallOptions"
option-attribute="label"
value-attribute="value"
size="sm"
class="w-full sm:w-52"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 hidden sm:inline">
@@ -201,11 +212,14 @@
{{displayCurrency(calculateDocSum(row))}}
</template>
<template #serialConfig.intervall-data="{row}">
{{ row.serialConfig?.intervall }}
{{ getIntervallLabel(row.serialConfig?.intervall) }}
</template>
<template #contract-data="{row}">
{{row.contract?.contractNumber}} - {{row.contract?.name}}
</template>
<template #plant-data="{row}">
{{ row.plant?.name || "-" }}
</template>
</UTable>
</div>
@@ -287,6 +301,7 @@ const executionDate = ref(dayjs().format('YYYY-MM-DD'))
const selectedExecutionRows = ref([])
const isExecuting = ref(false)
const modalSearch = ref("") // NEU: Suchstring für das Modal
const selectedExecutionIntervall = ref("all")
// --- SerialExecutions State ---
const showExecutionsSlideover = ref(false)
@@ -295,7 +310,7 @@ const executionsLoading = ref(false)
const finishingId = ref(null)
const setupPage = async () => {
items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber)","documentDate",undefined,true)
items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber), plant(id,name)","documentDate",undefined,true)
await fetchExecutions()
}
@@ -390,30 +405,78 @@ const filteredRows = computed(() => {
return useSearch(searchString.value, temp.slice().reverse())
})
// Basis Liste für das Modal (nur Aktive)
// Basis Liste für das Modal (nur aktive und nicht archivierte Vorlagen)
const activeTemplates = computed(() => {
return items.value
.filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active)
.filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active && !i.archived)
.map(i => ({...i}))
})
const intervallLabelMap = {
"wöchentlich": "Wöchentlich",
"2 - wöchentlich": "Alle 2 Wochen",
"monatlich": "Monatlich",
"vierteljährlich": "Quartalsweise",
"halbjährlich": "Halbjährlich",
"jährlich": "Jährlich"
}
const getIntervallLabel = (intervall) => {
if (!intervall) return "-"
return intervallLabelMap[intervall] || intervall
}
const executionIntervallOptions = computed(() => {
const availableIntervals = [...new Set(
activeTemplates.value
.map(row => row.serialConfig?.intervall)
.filter(Boolean)
)]
const sorted = availableIntervals.sort((a, b) =>
getIntervallLabel(a).localeCompare(getIntervallLabel(b), 'de')
)
return [
{label: 'Alle Intervalle', value: 'all'},
...sorted.map(intervall => ({
label: getIntervallLabel(intervall),
value: intervall
}))
]
})
// NEU: Gefilterte Liste für das Modal basierend auf der Suche
const filteredExecutionList = computed(() => {
if (!modalSearch.value) return activeTemplates.value
let filtered = [...activeTemplates.value]
if (selectedExecutionIntervall.value !== 'all') {
filtered = filtered.filter(
row => row.serialConfig?.intervall === selectedExecutionIntervall.value
)
}
if (!modalSearch.value) return filtered
const term = modalSearch.value.toLowerCase()
return activeTemplates.value.filter(row => {
return filtered.filter(row => {
const customerName = row.customer?.name?.toLowerCase() || ""
const contractNum = row.contract?.contractNumber?.toLowerCase() || ""
const contractName = row.contract?.name?.toLowerCase() || ""
const plantName = row.plant?.name?.toLowerCase() || ""
return customerName.includes(term) ||
contractNum.includes(term) ||
contractName.includes(term)
contractName.includes(term) ||
plantName.includes(term)
})
})
watch(selectedExecutionIntervall, () => {
selectedExecutionRows.value = [...filteredExecutionList.value]
})
// NEU: Alle auswählen (nur die aktuell sichtbaren/gefilterten)
const selectAllTemplates = () => {
// WICHTIG: Überschreibt nicht bestehende Auswahl, sondern fügt hinzu oder ersetzt.
@@ -469,6 +532,7 @@ const templateColumns = [
const executionColumns = [
{key: 'partner', label: "Kunde"},
{key: 'plant', label: "Objekt"},
{key: 'contract', label: "Vertrag"},
{key: 'serialConfig.intervall', label: "Intervall"},
{key: "amount", label: "Betrag"},
@@ -509,8 +573,9 @@ const calculateDocSum = (row) => {
const openExecutionModal = () => {
executionDate.value = dayjs().format('YYYY-MM-DD')
selectedExecutionRows.value = []
modalSearch.value = "" // Reset Search
selectedExecutionIntervall.value = "all"
selectedExecutionRows.value = []
showExecutionModal.value = true
}
@@ -557,4 +622,4 @@ const executeSerialInvoices = async () => {
}
setupPage()
</script>
</script>

View File

@@ -18,7 +18,10 @@ defineShortcuts({
'Enter': {
usingInput: true,
handler: () => {
router.push(`/incomingInvoices/show/${filteredRows.value[selectedItem.value].id}`)
const invoice = filteredRows.value[selectedItem.value]
if (invoice) {
selectIncomingInvoice(invoice)
}
}
},
'arrowdown': () => {
@@ -146,13 +149,11 @@ const isPaid = (item) => {
}
const selectIncomingInvoice = (invoice) => {
if(invoice.state === "Vorbereitet" ) {
router.push(`/incomingInvoices/edit/${invoice.id}`)
} else {
if (invoice.state === "Gebucht") {
router.push(`/incomingInvoices/show/${invoice.id}`)
} else {
router.push(`/incomingInvoices/edit/${invoice.id}`)
}
}
@@ -291,4 +292,4 @@ const selectIncomingInvoice = (invoice) => {
<style scoped>
</style>
</style>

View File

@@ -14,7 +14,7 @@
<UDashboardPanelContent>
<div class="mb-5">
<UDashboardCard
title="Einnahmen und Ausgaben(netto)"
title="Einnahmen und Ausgaben"
class="mt-3"
>
<display-income-and-expenditure/>
@@ -89,4 +89,4 @@ const { isNotificationsSlideoverOpen } = useDashboard()
<style scoped>
</style>
</style>

View File

@@ -1,6 +1,108 @@
<script setup>
const auth = useAuthStore()
const defaultFeatures = {
objects: true,
calendar: true,
contacts: true,
projects: true,
vehicles: true,
contracts: true,
inventory: true,
accounting: true,
timeTracking: true,
planningBoard: true,
workingTimeTracking: true,
dashboard: true,
historyitems: true,
tasks: true,
wiki: true,
files: true,
createdletters: true,
documentboxes: true,
helpdesk: true,
email: true,
members: true,
customers: true,
vendors: true,
contactsList: true,
staffTime: true,
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
costcentres: true,
accounts: true,
ownaccounts: true,
banking: true,
spaces: true,
customerspaces: true,
customerinventoryitems: true,
inventoryitems: true,
inventoryitemgroups: true,
products: true,
productcategories: true,
services: true,
servicecategories: true,
memberrelations: true,
staffProfiles: true,
hourrates: true,
projecttypes: true,
contracttypes: true,
plants: true,
settingsNumberRanges: true,
settingsEmailAccounts: true,
settingsBanking: true,
settingsTexttemplates: true,
settingsTenant: true,
export: true,
}
const featureOptions = [
{ key: "dashboard", label: "Dashboard" },
{ key: "historyitems", label: "Logbuch" },
{ key: "tasks", label: "Aufgaben" },
{ key: "wiki", label: "Wiki" },
{ key: "files", label: "Dateien" },
{ key: "createdletters", label: "Anschreiben" },
{ key: "documentboxes", label: "Boxen" },
{ key: "helpdesk", label: "Helpdesk" },
{ key: "email", label: "E-Mail" },
{ key: "members", label: "Mitglieder" },
{ key: "customers", label: "Kunden" },
{ key: "vendors", label: "Lieferanten" },
{ key: "contactsList", label: "Ansprechpartner" },
{ key: "staffTime", label: "Mitarbeiter: Zeiten" },
{ key: "createDocument", label: "Buchhaltung: Ausgangsbelege" },
{ key: "serialInvoice", label: "Buchhaltung: Serienvorlagen" },
{ key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" },
{ key: "costcentres", label: "Buchhaltung: Kostenstellen" },
{ key: "accounts", label: "Buchhaltung: Buchungskonten" },
{ key: "ownaccounts", label: "Buchhaltung: Zusätzliche Buchungskonten" },
{ key: "banking", label: "Buchhaltung: Bank" },
{ key: "spaces", label: "Lagerplätze" },
{ key: "customerspaces", label: "Kundenlagerplätze" },
{ key: "customerinventoryitems", label: "Kundeninventar" },
{ key: "inventoryitems", label: "Inventar" },
{ key: "inventoryitemgroups", label: "Inventargruppen" },
{ key: "products", label: "Stammdaten: Artikel" },
{ key: "productcategories", label: "Stammdaten: Artikelkategorien" },
{ key: "services", label: "Stammdaten: Leistungen" },
{ key: "servicecategories", label: "Stammdaten: Leistungskategorien" },
{ key: "memberrelations", label: "Stammdaten: Mitgliedsverhältnisse" },
{ key: "staffProfiles", label: "Stammdaten: Mitarbeiter" },
{ key: "hourrates", label: "Stammdaten: Stundensätze" },
{ key: "projecttypes", label: "Stammdaten: Projekttypen" },
{ key: "contracttypes", label: "Stammdaten: Vertragstypen" },
{ key: "vehicles", label: "Stammdaten: Fahrzeuge" },
{ key: "projects", label: "Projekte" },
{ key: "contracts", label: "Verträge" },
{ key: "plants", label: "Objekte" },
{ key: "settingsNumberRanges", label: "Einstellungen: Nummernkreise" },
{ key: "settingsEmailAccounts", label: "Einstellungen: E-Mail Konten" },
{ key: "settingsBanking", label: "Einstellungen: Bankkonten" },
{ key: "settingsTexttemplates", label: "Einstellungen: Textvorlagen" },
{ key: "settingsTenant", label: "Einstellungen: Firmeneinstellungen" },
{ key: "export", label: "Einstellungen: Export" },
]
const itemInfo = ref({
features: {},
@@ -13,8 +115,13 @@ const setupPage = async () => {
console.log(itemInfo.value)
}
const features = ref(auth.activeTenantData.features)
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
const businessInfo = ref(auth.activeTenantData.businessInfo)
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const accountChartOptions = [
{ label: "SKR 03", value: "skr03" },
{ label: "Verein", value: "verein" }
]
const updateTenant = async (newData) => {
@@ -24,6 +131,15 @@ const updateTenant = async (newData) => {
data: newData,
}
})
if (res) {
itemInfo.value = res
auth.activeTenantData = res
features.value = { ...defaultFeatures, ...(res?.features || {}) }
}
}
const saveFeatures = async () => {
await updateTenant({features: features.value})
}
setupPage()
@@ -40,6 +156,8 @@ setupPage()
label: 'Dokubox'
},{
label: 'Rechnung & Kontakt'
},{
label: 'Funktionen'
}
]"
>
@@ -63,8 +181,8 @@ setupPage()
</div>
<div v-if="item.label === 'Rechnung & Kontakt'">
<UCard class="mt-5">
<UForm class="w-1/2">
<UCard class="mt-5">
<UForm class="w-1/2">
<UFormGroup
label="Firmenname:"
>
@@ -90,6 +208,23 @@ setupPage()
>
Speichern
</UButton>
<UFormGroup
label="Kontenrahmen:"
class="mt-6"
>
<USelectMenu
v-model="accountChart"
:options="accountChartOptions"
option-attribute="label"
value-attribute="value"
/>
</UFormGroup>
<UButton
class="mt-3"
@click="updateTenant({accountChart: accountChart})"
>
Kontenrahmen speichern
</UButton>
</UForm>
</UCard>
@@ -104,59 +239,11 @@ setupPage()
class="mb-5"
/>
<UCheckbox
label="Kalendar"
v-model="features.calendar"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Kontakte"
v-model="features.contacts"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Plantafel"
v-model="features.planningBoard"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Zeiterfassung"
v-model="features.timeTracking"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Anwesenheiten"
v-model="features.workingTimeTracking"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Lager"
v-model="features.inventory"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Fahrzeuge"
v-model="features.vehicles"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Buchhaltung"
v-model="features.accounting"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Projekte"
v-model="features.projects"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Verträge"
v-model="features.contracts"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Objekte"
v-model="features.objects"
@change="updateTenant({features: features})"
v-for="option in featureOptions"
:key="option.key"
:label="option.label"
v-model="features[option.key]"
@change="saveFeatures"
/>
</UCard>
</div>

View File

@@ -142,13 +142,6 @@ function getAssigneeLabel(task) {
return assigneeOptions.value.find((option) => option.value === assigneeId)?.label || "-"
}
function getStatusBadgeColor(status) {
const normalized = normalizeStatus(status)
if (normalized === "Offen") return "gray"
if (normalized === "In Bearbeitung") return "amber"
return "green"
}
async function loadTasks() {
loading.value = true
try {
@@ -368,7 +361,6 @@ onMounted(async () => {
/>
<UButton
icon="i-heroicons-arrow-path"
color="gray"
variant="soft"
:loading="loading"
@click="loadTasks"
@@ -391,15 +383,15 @@ onMounted(async () => {
v-model="showOnlyMine"
label="Nur meine Aufgaben"
/>
<span v-else class="text-sm text-gray-500">Ansicht: Nur eigene Aufgaben</span>
<span v-else class="text-sm">Ansicht: Nur eigene Aufgaben</span>
</template>
<template #right>
<div class="flex items-center gap-1 rounded-lg border border-gray-200 p-1 dark:border-gray-700">
<div class="view-toggle">
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-view-columns"
:color="viewMode === 'kanban' ? 'primary' : 'gray'"
:variant="viewMode === 'kanban' ? 'solid' : 'ghost'"
@click="viewMode = 'kanban'"
>
Kanban
@@ -408,7 +400,7 @@ onMounted(async () => {
size="xs"
variant="ghost"
icon="i-heroicons-list-bullet"
:color="viewMode === 'list' ? 'primary' : 'gray'"
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
@click="viewMode = 'list'"
>
Liste
@@ -429,7 +421,7 @@ onMounted(async () => {
>
<header class="kanban-column-header">
<h3>{{ status }}</h3>
<UBadge color="gray" variant="subtle">{{ groupedTasks[status]?.length || 0 }}</UBadge>
<UBadge variant="subtle">{{ groupedTasks[status]?.length || 0 }}</UBadge>
</header>
<div :class="['kanban-dropzone', droppingOn === status ? 'kanban-dropzone-active' : '']">
@@ -445,13 +437,13 @@ onMounted(async () => {
<p v-if="task.description" class="kanban-card-description">{{ task.description }}</p>
<div class="kanban-card-meta">
<UBadge v-if="getEntityLabel(projectOptions, task.project?.id || task.project)" color="primary" variant="soft">
<UBadge v-if="getEntityLabel(projectOptions, task.project?.id || task.project)" variant="soft">
{{ getEntityLabel(projectOptions, task.project?.id || task.project) }}
</UBadge>
<UBadge v-if="getEntityLabel(customerOptions, task.customer?.id || task.customer)" color="gray" variant="soft">
<UBadge v-if="getEntityLabel(customerOptions, task.customer?.id || task.customer)" variant="soft">
{{ getEntityLabel(customerOptions, task.customer?.id || task.customer) }}
</UBadge>
<UBadge v-if="getEntityLabel(plantOptions, task.plant?.id || task.plant)" color="gray" variant="soft">
<UBadge v-if="getEntityLabel(plantOptions, task.plant?.id || task.plant)" variant="soft">
{{ getEntityLabel(plantOptions, task.plant?.id || task.plant) }}
</UBadge>
</div>
@@ -464,17 +456,15 @@ onMounted(async () => {
</section>
</div>
<UTable
v-else
v-else-if="filteredTasks.length"
:rows="filteredTasks"
:columns="listColumns"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
@select="(task) => openTaskViaRoute(task)"
>
<template #actions-data="{ row }">
<UButton
v-if="normalizeStatus(row.categorie) !== 'Abgeschlossen' && canCreate"
size="xs"
color="green"
variant="soft"
icon="i-heroicons-check"
:loading="quickCompleteLoadingId === row.id"
@@ -484,9 +474,7 @@ onMounted(async () => {
</UButton>
</template>
<template #categorie-data="{ row }">
<UBadge :color="getStatusBadgeColor(row.categorie)" variant="soft">
{{ normalizeStatus(row.categorie) }}
</UBadge>
<UBadge variant="soft">{{ normalizeStatus(row.categorie) }}</UBadge>
</template>
<template #assignee-data="{ row }">
{{ getAssigneeLabel(row) }}
@@ -501,6 +489,12 @@ onMounted(async () => {
{{ getEntityLabel(plantOptions, row.plant?.id || row.plant) || "-" }}
</template>
</UTable>
<UAlert
v-else
icon="i-heroicons-circle-stack-20-solid"
title="Keine Aufgaben anzuzeigen"
variant="subtle"
/>
</UDashboardPanelContent>
<UModal v-model="isModalOpen" :prevent-close="saving || deleting">
@@ -508,7 +502,7 @@ onMounted(async () => {
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ modalTitle }}</h3>
<UBadge color="gray" variant="subtle">{{ taskForm.id ? `#${taskForm.id}` : "Neu" }}</UBadge>
<UBadge variant="subtle">{{ taskForm.id ? `#${taskForm.id}` : "Neu" }}</UBadge>
</div>
</template>
@@ -595,7 +589,6 @@ onMounted(async () => {
<div class="flex gap-2">
<UButton
v-if="taskForm.id && canCreate"
color="red"
variant="soft"
:loading="deleting"
@click="archiveTask"
@@ -605,10 +598,9 @@ onMounted(async () => {
</div>
<div class="flex gap-2">
<UButton color="gray" variant="ghost" @click="closeModal">Schließen</UButton>
<UButton variant="ghost" @click="closeModal">Schließen</UButton>
<UButton
v-if="modalMode === 'show' && canCreate"
color="gray"
variant="soft"
@click="modalMode = 'edit'"
>
@@ -632,7 +624,8 @@ onMounted(async () => {
.kanban-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
gap: 1.25rem;
align-items: start;
}
@media (min-width: 1024px) {
@@ -642,38 +635,56 @@ onMounted(async () => {
}
.kanban-column {
border: 1px solid rgb(229 231 235);
border: 1px solid var(--ui-border);
border-radius: 0.75rem;
background: rgb(249 250 251);
background: var(--ui-bg);
min-height: 500px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 1px 2px color-mix(in oklab, var(--ui-text) 10%, transparent);
}
@media (min-width: 1024px) {
.kanban-column:not(:first-child)::before {
content: "";
position: absolute;
left: -0.7rem;
top: 0.75rem;
bottom: 0.75rem;
width: 1px;
background: var(--ui-border);
opacity: 0.9;
pointer-events: none;
}
}
.kanban-column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid rgb(229 231 235);
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-bg-muted);
}
.kanban-dropzone {
padding: 0.75rem;
padding: 0.9rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.85rem;
transition: background-color 0.2s ease;
}
.kanban-dropzone-active {
background: rgb(239 246 255);
background: var(--ui-bg-muted);
}
.kanban-card {
border: 1px solid rgb(229 231 235);
background: white;
border: 1px solid var(--ui-border);
background: var(--ui-bg-elevated);
border-radius: 0.6rem;
padding: 0.7rem;
cursor: pointer;
@@ -686,7 +697,7 @@ onMounted(async () => {
.kanban-card-description {
margin-top: 0.4rem;
color: rgb(107 114 128);
color: var(--ui-text-dimmed);
font-size: 0.875rem;
line-height: 1.15rem;
}
@@ -699,8 +710,8 @@ onMounted(async () => {
}
.kanban-empty {
border: 1px dashed rgb(209 213 219);
color: rgb(156 163 175);
border: 1px dashed var(--ui-border);
color: var(--ui-text-dimmed);
border-radius: 0.6rem;
padding: 0.7rem;
font-size: 0.875rem;
@@ -711,36 +722,15 @@ onMounted(async () => {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--ui-text);
}
:global(.dark) .kanban-column {
border-color: rgb(55 65 81);
background: rgb(17 24 39);
}
:global(.dark) .kanban-column-header {
border-bottom-color: rgb(55 65 81);
}
:global(.dark) .kanban-dropzone-active {
background: rgb(30 41 59);
}
:global(.dark) .kanban-card {
border-color: rgb(75 85 99);
background: rgb(31 41 55);
}
:global(.dark) .kanban-card-description {
color: rgb(209 213 219);
}
:global(.dark) .kanban-empty {
border-color: rgb(75 85 99);
color: rgb(156 163 175);
}
:global(.dark) .form-label {
color: rgb(229 231 235);
.view-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--ui-border);
border-radius: 0.5rem;
padding: 0.25rem;
}
</style>

View File

@@ -535,7 +535,7 @@ export const useDataStore = defineStore('data', () => {
disabledInTable: true
},
{
key: "infoData.memberrelation",
key: "memberrelation",
label: "Mitgliedsverhältnis",
component: memberrelation,
inputType: "select",
@@ -1237,9 +1237,9 @@ export const useDataStore = defineStore('data', () => {
selectOptionAttribute: "name",
selectSearchAttributes: ['name'],
},{
key: "description",
key: "description.text",
label: "Beschreibung",
inputType:"editor",
inputType:"textarea",
component: description
},
],

View File

@@ -1,17 +1,32 @@
{
"expo": {
"name": "mobile",
"slug": "mobile",
"name": "FEDEO",
"slug": "fedeo-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobile",
"scheme": "fedeo",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "de.fedeo.mobile",
"buildNumber": "1",
"infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
"NSPhotoLibraryAddUsageDescription": "Die App benötigt Zugriff, um Fotos für Uploads zu speichern und zu verwenden.",
"NSBluetoothAlwaysUsageDescription": "Bluetooth wird benötigt, um den Nimbot M2 Etikettendrucker zu verbinden.",
"NSBluetoothPeripheralUsageDescription": "Bluetooth wird benötigt, um mit dem Nimbot M2 zu kommunizieren."
}
},
"android": {
"package": "de.fedeo.mobile",
"permissions": [
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH_CONNECT",
"android.permission.ACCESS_FINE_LOCATION"
],
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
@@ -38,7 +53,8 @@
"backgroundColor": "#000000"
}
}
]
],
"react-native-ble-plx"
],
"experiments": {
"typedRoutes": true,

View File

@@ -31,16 +31,23 @@ export default function TabLayout() {
}}>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="tasks"
options={{
title: 'Aufgaben',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="checklist" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
name="projects"
options={{
title: 'Konto',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="person.crop.circle.fill" color={color} />,
title: 'Projekte',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="folder.fill" color={color} />,
}}
/>
<Tabs.Screen
@@ -50,6 +57,13 @@ export default function TabLayout() {
tabBarIcon: ({ color }) => <IconSymbol size={24} name="clock.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Mehr',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="ellipsis.circle.fill" color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -1,187 +1,139 @@
import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
export default function AccountScreen() {
const { token, user, tenants, activeTenantId, activeTenant, switchTenant, logout } = useAuth();
const storageInfo = useTokenStorageInfo();
const [switchingTenantId, setSwitchingTenantId] = useState<number | null>(null);
const [switchError, setSwitchError] = useState<string | null>(null);
const userId = String(user?.id || 'unbekannt');
async function onSwitchTenant(tenantId: number) {
setSwitchingTenantId(tenantId);
setSwitchError(null);
try {
await switchTenant(tenantId);
} catch (err) {
setSwitchError(err instanceof Error ? err.message : 'Tenant konnte nicht gewechselt werden.');
} finally {
setSwitchingTenantId(null);
}
}
const ITEMS = [
{
key: 'account',
title: 'Konto',
subtitle: 'Session, Tenant-Wechsel, Logout',
href: '/more/account',
},
{
key: 'settings',
title: 'Einstellungen',
subtitle: 'Server-Instanz verwalten',
href: '/more/settings',
},
{
key: 'wiki',
title: 'Wiki',
subtitle: 'Wissen und Dokumentation',
href: '/more/wiki',
},
{
key: 'customers',
title: 'Kunden',
subtitle: '',
href: '/more/customers',
},
{
key: 'plants',
title: 'Objekte',
subtitle: '',
href: '/more/plants',
},
{
key: 'inventory',
title: 'Kundeninventar',
subtitle: 'Inventar und Scanner',
href: '/more/inventory',
},
{
key: 'nimbot',
title: 'Nimbot M2',
subtitle: 'Drucker verbinden',
href: '/more/nimbot',
},
];
export default function MoreScreen() {
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Konto</Text>
<Text style={styles.subtitle}>Session-Infos und Tenant-Wechsel.</Text>
<View style={styles.screen}>
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Funktionen</Text>
<Text style={styles.sectionSubtitle}>Weitere Bereiche und Einstellungen.</Text>
</View>
<View style={styles.card}>
<Text style={styles.label}>Token vorhanden</Text>
<Text style={styles.value}>{token ? 'ja' : 'nein'}</Text>
<Text style={styles.label}>User ID</Text>
<Text style={styles.value}>{userId}</Text>
<Text style={styles.label}>Aktiver Tenant</Text>
<Text style={styles.value}>{activeTenant ? `${activeTenant.name} (#${activeTenantId})` : 'nicht gesetzt'}</Text>
<Text style={styles.label}>Storage Modus</Text>
<Text style={styles.value}>{storageInfo.mode}</Text>
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>Tenant wechseln</Text>
{switchError ? <Text style={styles.error}>{switchError}</Text> : null}
{tenants.map((tenant) => {
const tenantId = Number(tenant.id);
const isActive = tenantId === activeTenantId;
const isSwitching = switchingTenantId === tenantId;
return (
<Pressable
key={String(tenant.id)}
style={[
styles.tenantButton,
isActive ? styles.tenantButtonActive : null,
isSwitching ? styles.tenantButtonDisabled : null,
]}
onPress={() => onSwitchTenant(tenantId)}
disabled={isActive || switchingTenantId !== null}>
<View style={styles.tenantInfo}>
<Text style={styles.tenantName} numberOfLines={2} ellipsizeMode="tail">
{tenant.name}
</Text>
<Text style={styles.tenantMeta}>ID: {tenantId}</Text>
</View>
<View style={styles.tenantActionWrap}>
<Text style={styles.tenantAction}>{isActive ? 'Aktiv' : isSwitching ? 'Wechsel...' : 'Wechseln'}</Text>
</View>
</Pressable>
);
})}
</View>
<Pressable style={styles.logoutButton} onPress={logout}>
<Text style={styles.logoutText}>Logout</Text>
</Pressable>
</ScrollView>
{ITEMS.map((item, index) => (
<Pressable key={item.key} style={styles.row} onPress={() => router.push(item.href as any)}>
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>{item.title}</Text>
<Text style={styles.rowSubtitle}>{item.subtitle}</Text>
</View>
<Text style={styles.rowArrow}></Text>
{index < ITEMS.length - 1 ? <View style={styles.rowDivider} /> : null}
</Pressable>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
backgroundColor: '#f9fafb',
padding: 16,
gap: 12,
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#111827',
},
subtitle: {
color: '#6b7280',
},
card: {
screen: {
flex: 1,
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
gap: 8,
},
list: {
flex: 1,
backgroundColor: '#ffffff',
},
listContent: {
paddingBottom: 24,
},
section: {
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 2,
fontSize: 17,
fontWeight: '700',
},
label: {
fontSize: 12,
color: '#6b7280',
textTransform: 'uppercase',
},
value: {
fontSize: 16,
color: '#111827',
marginBottom: 2,
},
error: {
color: '#dc2626',
marginBottom: 4,
},
tenantButton: {
borderRadius: 10,
padding: 12,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
tenantButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
tenantButtonDisabled: {
opacity: 0.6,
},
tenantInfo: {
flex: 1,
minWidth: 0,
paddingRight: 10,
},
tenantName: {
fontSize: 15,
fontWeight: '600',
color: '#111827',
flexShrink: 1,
},
tenantMeta: {
sectionSubtitle: {
color: '#6b7280',
fontSize: 13,
marginTop: 3,
},
tenantActionWrap: {
minWidth: 84,
alignItems: 'flex-end',
},
tenantAction: {
color: PRIMARY,
fontWeight: '600',
textAlign: 'right',
},
logoutButton: {
marginTop: 4,
backgroundColor: '#dc2626',
minHeight: 44,
borderRadius: 10,
row: {
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#ffffff',
position: 'relative',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
justifyContent: 'space-between',
gap: 10,
},
logoutText: {
color: '#ffffff',
rowMain: {
flex: 1,
minWidth: 0,
},
rowTitle: {
color: '#111827',
fontSize: 15,
fontWeight: '600',
fontSize: 16,
},
rowSubtitle: {
color: '#6b7280',
fontSize: 13,
marginTop: 2,
},
rowArrow: {
color: '#9ca3af',
fontSize: 24,
lineHeight: 24,
},
rowDivider: {
position: 'absolute',
left: 16,
right: 16,
bottom: 0,
height: 1,
backgroundColor: '#e5e7eb',
},
});

View File

@@ -1,416 +1,258 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { createTask, fetchTasks, fetchTenantProfiles, Task, TaskStatus, updateTask } from '@/src/lib/api';
import { fetchStaffTimeSpans, fetchTasks, Task } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const STATUSES: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
const PRIMARY = '#69c350';
function normalizeStatus(status: unknown): TaskStatus {
if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status;
type DashboardData = {
tasks: Task[];
openTasks: number;
inProgressTasks: number;
activeTimeStart: string | null;
pendingSubmissions: number;
todayMinutes: number;
};
function normalizeTaskStatus(value: unknown): 'Offen' | 'In Bearbeitung' | 'Abgeschlossen' {
if (value === 'In Bearbeitung') return 'In Bearbeitung';
if (value === 'Abgeschlossen') return 'Abgeschlossen';
return 'Offen';
}
function getTaskAssigneeId(task: Task): string | null {
return (task.userId || task.user_id || task.profile || null) as string | null;
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}h ${String(m).padStart(2, '0')}m`;
}
export default function TasksScreen() {
const { token, user, activeTenantId } = useAuth();
function formatDateTime(value: string | null): string {
if (!value) return '-';
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export default function DashboardScreen() {
const { token, user } = useAuth();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [profiles, setProfiles] = useState<{ id: string; label: string }[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<'Alle' | TaskStatus>('Alle');
const [showCompleted, setShowCompleted] = useState(false);
const [showSearchPanel, setShowSearchPanel] = useState(false);
const [showFilterPanel, setShowFilterPanel] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newTaskName, setNewTaskName] = useState('');
const [newTaskDescription, setNewTaskDescription] = useState('');
const [data, setData] = useState<DashboardData>({
tasks: [],
openTasks: 0,
inProgressTasks: 0,
activeTimeStart: null,
pendingSubmissions: 0,
todayMinutes: 0,
});
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const filteredTasks = useMemo(() => {
const needle = search.trim().toLowerCase();
return tasks
.filter((task) => {
const status = normalizeStatus(task.categorie);
if (!showCompleted && status === 'Abgeschlossen') return false;
const statusMatch = statusFilter === 'Alle' || status === statusFilter;
const textMatch =
!needle ||
[task.name, task.description, task.categorie].some((value) =>
String(value || '').toLowerCase().includes(needle)
);
return statusMatch && textMatch;
})
.sort((a, b) => Number(a.id) - Number(b.id));
}, [search, showCompleted, statusFilter, tasks]);
function getAssigneeLabel(task: Task): string {
const assigneeId = getTaskAssigneeId(task);
if (!assigneeId) return '-';
return profiles.find((profile) => profile.id === assigneeId)?.label || assigneeId;
}
const loadTasks = useCallback(
const loadDashboard = useCallback(
async (showSpinner = true) => {
if (!token) return;
if (!token || !currentUserId) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [taskRows, profileRows] = await Promise.all([fetchTasks(token), fetchTenantProfiles(token)]);
setTasks(taskRows || []);
setProfiles(
(profileRows || [])
.map((profile) => {
const id = profile.user_id || (profile.id ? String(profile.id) : null);
const label = profile.full_name || profile.fullName || profile.email || id;
return id ? { id: String(id), label: String(label || id) } : null;
})
.filter((value): value is { id: string; label: string } => Boolean(value))
);
const [taskRows, spans] = await Promise.all([
fetchTasks(token),
fetchStaffTimeSpans(token, currentUserId),
]);
const tasks = taskRows || [];
const openTasks = tasks.filter((task) => normalizeTaskStatus(task.categorie) === 'Offen').length;
const inProgressTasks = tasks.filter(
(task) => normalizeTaskStatus(task.categorie) === 'In Bearbeitung'
).length;
const activeTime = spans.find((span) => !span.stopped_at) || null;
const pendingSubmissions = spans.filter(
(span) => (span.state === 'draft' || span.state === 'factual') && !!span.stopped_at
).length;
const today = new Date();
const todayIso = today.toISOString().slice(0, 10);
const todayMinutes = spans
.filter((span) => span.started_at?.slice(0, 10) === todayIso)
.reduce((sum, span) => sum + (span.duration_minutes || 0), 0);
setData({
tasks,
openTasks,
inProgressTasks,
activeTimeStart: activeTime?.started_at || null,
pendingSubmissions,
todayMinutes,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Aufgaben konnten nicht geladen werden.');
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[token]
[currentUserId, token]
);
useEffect(() => {
if (!token || !activeTenantId) return;
void loadTasks(true);
}, [token, activeTenantId, loadTasks]);
if (!token || !currentUserId) return;
void loadDashboard(true);
}, [currentUserId, loadDashboard, token]);
async function onRefresh() {
if (!token) return;
setRefreshing(true);
await loadTasks(false);
}
function closeCreateModal() {
setCreateModalOpen(false);
setCreateError(null);
setNewTaskName('');
setNewTaskDescription('');
}
async function onCreateTask() {
if (!token) return;
const name = newTaskName.trim();
if (!name) {
setCreateError('Bitte einen Aufgabennamen eingeben.');
return;
}
setSaving(true);
setCreateError(null);
try {
await createTask(token, {
name,
description: newTaskDescription.trim() || null,
categorie: 'Offen',
userId: currentUserId,
});
closeCreateModal();
await loadTasks(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}
async function setTaskStatus(task: Task, status: TaskStatus) {
if (!token || !task?.id) return;
if (normalizeStatus(task.categorie) === status) return;
setUpdatingTaskId(Number(task.id));
setError(null);
try {
await updateTask(token, Number(task.id), { categorie: status });
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, categorie: status } : item)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Status konnte nicht gesetzt werden.');
} finally {
setUpdatingTaskId(null);
}
await loadDashboard(false);
}
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.topActions}>
<Pressable
style={[styles.topActionButton, showSearchPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowSearchPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showSearchPanel ? styles.topActionTextActive : null]}>Suche</Text>
</Pressable>
<Pressable
style={[styles.topActionButton, showFilterPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowFilterPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showFilterPanel ? styles.topActionTextActive : null]}>Filter</Text>
</Pressable>
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Dashboard wird geladen...</Text>
</View>
) : null}
{showSearchPanel ? (
<View style={styles.panel}>
<TextInput
placeholder="Suche"
placeholderTextColor="#9ca3af"
style={styles.input}
value={search}
onChangeText={setSearch}
/>
{!loading ? (
<>
<View style={styles.row}>
<View style={[styles.metricCard, styles.metricCardPrimary]}>
<Text style={styles.metricLabelPrimary}>Aktive Zeit</Text>
<Text style={styles.metricValuePrimary}>
{data.activeTimeStart ? `Seit ${formatDateTime(data.activeTimeStart)}` : 'Nicht aktiv'}
</Text>
</View>
</View>
) : null}
{showFilterPanel ? (
<View style={styles.panel}>
<View style={styles.filterRow}>
{(['Alle', 'Offen', 'In Bearbeitung'] as const).map((status) => (
<Pressable
key={status}
style={[styles.filterChip, statusFilter === status ? styles.filterChipActive : null]}
onPress={() => setStatusFilter(status)}>
<Text
style={[
styles.filterChipText,
statusFilter === status ? styles.filterChipTextActive : null,
]}>
{status}
</Text>
</Pressable>
))}
<Pressable
style={[styles.filterChip, showCompleted ? styles.filterChipActive : null]}
onPress={() => setShowCompleted((prev) => !prev)}>
<Text style={[styles.filterChipText, showCompleted ? styles.filterChipTextActive : null]}>
Abgeschlossene anzeigen
</Text>
<View style={styles.row}>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Offene Aufgaben</Text>
<Text style={styles.metricValue}>{data.openTasks}</Text>
</View>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>In Bearbeitung</Text>
<Text style={styles.metricValue}>{data.inProgressTasks}</Text>
</View>
</View>
<View style={styles.row}>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Heute erfasst</Text>
<Text style={styles.metricValue}>{formatMinutes(data.todayMinutes)}</Text>
</View>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Zum Einreichen</Text>
<Text style={styles.metricValue}>{data.pendingSubmissions}</Text>
</View>
</View>
<View style={styles.quickActionsCard}>
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
<View style={styles.quickActionsRow}>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/tasks')}>
<Text style={styles.quickActionText}>Aufgaben</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/projects')}>
<Text style={styles.quickActionText}>Projekten</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/time')}>
<Text style={styles.quickActionText}>Zeiten</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/more/inventory?action=scan')}>
<Text style={styles.quickActionText}>Inventar Scan</Text>
</Pressable>
</View>
</View>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Aufgaben werden geladen...</Text>
</View>
) : null}
{!loading && filteredTasks.length === 0 ? (
<Text style={styles.empty}>Keine Aufgaben gefunden.</Text>
) : null}
{!loading &&
filteredTasks.map((task) => {
const status = normalizeStatus(task.categorie);
const isUpdating = updatingTaskId === Number(task.id);
return (
<View key={String(task.id)} style={styles.taskCard}>
<View style={styles.taskHeader}>
<Text style={styles.taskTitle} numberOfLines={2}>
{task.name}
</Text>
<Text style={styles.statusBadge}>{status}</Text>
</View>
{task.description ? (
<Text style={styles.taskDescription} numberOfLines={3}>
{task.description}
</Text>
) : null}
<Text style={styles.taskMeta}>Zuweisung: {getAssigneeLabel(task)}</Text>
<View style={styles.actionRow}>
{STATUSES.map((nextStatus) => (
<Pressable
key={nextStatus}
style={[
styles.actionButton,
nextStatus === status ? styles.actionButtonActive : null,
isUpdating ? styles.buttonDisabled : null,
]}
onPress={() => setTaskStatus(task, nextStatus)}
disabled={isUpdating || nextStatus === status}>
<Text style={styles.actionButtonText}>{nextStatus}</Text>
</Pressable>
))}
</View>
</View>
);
})}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateModalOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<Modal visible={createModalOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neue Aufgabe</Text>
<TextInput
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={styles.input}
value={newTaskName}
onChangeText={setNewTaskName}
/>
<TextInput
placeholder="Beschreibung (optional)"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.inputMultiline]}
multiline
value={newTaskDescription}
onChangeText={setNewTaskDescription}
/>
{createError ? <Text style={styles.error}>{createError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={onCreateTask}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
</>
) : null}
</ScrollView>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#f9fafb',
},
container: {
padding: 16,
gap: 12,
paddingBottom: 96,
backgroundColor: '#f9fafb',
},
topActions: {
row: {
flexDirection: 'row',
gap: 8,
gap: 10,
},
topActionButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
metricCard: {
flex: 1,
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 6,
},
topActionButtonActive: {
metricCardPrimary: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
topActionText: {
color: '#374151',
fontWeight: '600',
metricLabel: {
color: '#6b7280',
fontSize: 12,
textTransform: 'uppercase',
},
topActionTextActive: {
metricValue: {
color: '#111827',
fontSize: 22,
fontWeight: '700',
},
metricLabelPrimary: {
color: '#3d7a30',
fontSize: 12,
textTransform: 'uppercase',
},
panel: {
metricValuePrimary: {
color: '#2f5f24',
fontSize: 18,
fontWeight: '700',
},
quickActionsCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 10,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
quickActionsTitle: {
color: '#111827',
backgroundColor: '#ffffff',
fontSize: 15,
fontWeight: '600',
},
inputMultiline: {
minHeight: 72,
textAlignVertical: 'top',
},
filterRow: {
quickActionsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
filterChip: {
borderRadius: 999,
borderWidth: 1,
borderColor: '#d1d5db',
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
quickActionButton: {
minWidth: 120,
minHeight: 40,
borderRadius: 10,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
filterChipActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
filterChipText: {
color: '#374151',
fontSize: 13,
fontWeight: '500',
},
filterChipTextActive: {
color: '#3d7a30',
quickActionText: {
color: '#ffffff',
fontWeight: '600',
},
error: {
color: '#dc2626',
@@ -424,144 +266,4 @@ const styles = StyleSheet.create({
loadingText: {
color: '#6b7280',
},
empty: {
color: '#6b7280',
textAlign: 'center',
paddingVertical: 16,
},
taskCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
},
taskHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 8,
},
taskTitle: {
flex: 1,
color: '#111827',
fontSize: 16,
fontWeight: '600',
},
statusBadge: {
color: '#3d7a30',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 12,
overflow: 'hidden',
},
taskDescription: {
color: '#374151',
fontSize: 14,
},
taskMeta: {
color: '#6b7280',
fontSize: 12,
},
actionRow: {
flexDirection: 'row',
gap: 8,
flexWrap: 'wrap',
},
actionButton: {
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
paddingHorizontal: 10,
paddingVertical: 7,
},
actionButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
actionButtonText: {
color: '#1f2937',
fontSize: 12,
fontWeight: '500',
},
fab: {
position: 'absolute',
right: 18,
bottom: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
elevation: 4,
shadowColor: '#111827',
shadowOpacity: 0.18,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
},
fabText: {
color: '#ffffff',
fontSize: 30,
lineHeight: 30,
fontWeight: '500',
marginTop: -1,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'center',
padding: 16,
},
modalKeyboardWrap: {
width: '100%',
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 14,
gap: 10,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
marginTop: 2,
},
secondaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e5e7eb',
},
secondaryButtonText: {
color: '#111827',
fontWeight: '600',
},
primaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
},
});

View File

@@ -0,0 +1,533 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { router } from 'expo-router';
import { createProject, Customer, fetchCustomers, fetchPlants, fetchProjects, Plant, Project } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function getProjectLine(project: Project): string {
if (project.projectNumber) return `${project.name} · ${project.projectNumber}`;
return project.name;
}
function getActivePhaseLabel(project: Project): string {
const explicit = String(project.active_phase || '').trim();
if (explicit) return explicit;
const phases = Array.isArray(project.phases) ? project.phases : [];
const active = phases.find((phase: any) => phase?.active);
return String(active?.label || '').trim();
}
function isProjectCompletedByPhase(project: Project): boolean {
const phase = getActivePhaseLabel(project).toLowerCase();
return phase === 'abgeschlossen';
}
export default function ProjectsScreen() {
const { token } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [plants, setPlants] = useState<Plant[]>([]);
const [search, setSearch] = useState('');
const [showArchived, setShowArchived] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [nameInput, setNameInput] = useState('');
const [projectNumberInput, setProjectNumberInput] = useState('');
const [notesInput, setNotesInput] = useState('');
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
const [selectedPlantId, setSelectedPlantId] = useState<number | null>(null);
const [pickerMode, setPickerMode] = useState<'customer' | 'plant' | null>(null);
const [customerSearch, setCustomerSearch] = useState('');
const [plantSearch, setPlantSearch] = useState('');
const filteredProjects = useMemo(() => {
const terms = search
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
return projects.filter((project) => {
if (!showArchived && isProjectCompletedByPhase(project)) return false;
if (terms.length === 0) return true;
const haystack = [
project.name,
project.projectNumber,
project.notes,
project.customerRef,
project.active_phase,
getActivePhaseLabel(project),
]
.map((value) => String(value || '').toLowerCase())
.join(' ');
return terms.every((term) => haystack.includes(term));
});
}, [projects, search, showArchived]);
const selectedCustomerLabel = useMemo(() => {
if (!selectedCustomerId) return 'Kunde auswählen (optional)';
const customer = customers.find((item) => Number(item.id) === selectedCustomerId);
return customer ? `${customer.name}${customer.customerNumber ? ` · ${customer.customerNumber}` : ''}` : `ID ${selectedCustomerId}`;
}, [customers, selectedCustomerId]);
const selectedPlantLabel = useMemo(() => {
if (!selectedPlantId) return 'Objekt auswählen (optional)';
const plant = plants.find((item) => Number(item.id) === selectedPlantId);
return plant ? plant.name : `ID ${selectedPlantId}`;
}, [plants, selectedPlantId]);
const filteredCustomerOptions = useMemo(() => {
const terms = customerSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
return customers
.filter((customer) => !customer.archived)
.filter((customer) => {
if (terms.length === 0) return true;
const haystack = [customer.name, customer.customerNumber]
.map((value) => String(value || '').toLowerCase())
.join(' ');
return terms.every((term) => haystack.includes(term));
});
}, [customerSearch, customers]);
const filteredPlantOptions = useMemo(() => {
const terms = plantSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
return plants
.filter((plant) => !plant.archived)
.filter((plant) => {
if (terms.length === 0) return true;
const haystack = [plant.name, plant.description]
.map((value) => String(value || '').toLowerCase())
.join(' ');
return terms.every((term) => haystack.includes(term));
});
}, [plantSearch, plants]);
const loadProjects = useCallback(async (showSpinner = true) => {
if (!token) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [projectRows, customerRows, plantRows] = await Promise.all([
fetchProjects(token, true),
fetchCustomers(token, true),
fetchPlants(token, true),
]);
setProjects(projectRows);
setCustomers(customerRows);
setPlants(plantRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Projekte konnten nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [token]);
useEffect(() => {
if (!token) return;
void loadProjects(true);
}, [loadProjects, token]);
async function onRefresh() {
setRefreshing(true);
await loadProjects(false);
}
function closeCreateModal() {
setCreateOpen(false);
setCreateError(null);
setNameInput('');
setProjectNumberInput('');
setNotesInput('');
setSelectedCustomerId(null);
setSelectedPlantId(null);
setCustomerSearch('');
setPlantSearch('');
setPickerMode(null);
}
async function onCreateProject() {
if (!token) return;
const name = nameInput.trim();
if (!name) {
setCreateError('Bitte einen Projektnamen eingeben.');
return;
}
setSaving(true);
setCreateError(null);
try {
await createProject(token, {
name,
projectNumber: projectNumberInput.trim() || null,
customer: selectedCustomerId,
plant: selectedPlantId,
notes: notesInput.trim() || null,
});
closeCreateModal();
await loadProjects(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Projekt konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}
return (
<View style={styles.screen}>
<View style={styles.searchWrap}>
<TextInput
placeholder="Projekte suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={search}
onChangeText={setSearch}
/>
<Pressable
style={[styles.toggleButton, showArchived ? styles.toggleButtonActive : null]}
onPress={() => setShowArchived((prev) => !prev)}>
<Text style={[styles.toggleButtonText, showArchived ? styles.toggleButtonTextActive : null]}>
Abgeschlossene anzeigen
</Text>
</Pressable>
</View>
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Projekte werden geladen...</Text>
</View>
) : null}
{!loading && filteredProjects.length === 0 ? <Text style={styles.empty}>Keine Projekte gefunden.</Text> : null}
{!loading &&
filteredProjects.map((project) => (
<Pressable key={String(project.id)} style={styles.row} onPress={() => router.push(`/project/${project.id}`)}>
<View style={styles.rowHeader}>
<Text style={styles.rowTitle} numberOfLines={1}>{getProjectLine(project)}</Text>
{isProjectCompletedByPhase(project) ? <Text style={styles.archivedBadge}>Abgeschlossen</Text> : null}
</View>
{getActivePhaseLabel(project) ? (
<Text style={styles.phaseText} numberOfLines={1}>Phase: {getActivePhaseLabel(project)}</Text>
) : null}
{project.notes ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(project.notes)}</Text> : null}
</Pressable>
))}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<Modal visible={createOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neues Projekt</Text>
<TextInput
placeholder="Projektname"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={nameInput}
onChangeText={setNameInput}
/>
<TextInput
placeholder="Projekt-Nr. (optional)"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={projectNumberInput}
onChangeText={setProjectNumberInput}
/>
<Pressable style={styles.selectButton} onPress={() => setPickerMode('customer')}>
<Text style={styles.selectButtonText} numberOfLines={1}>{selectedCustomerLabel}</Text>
</Pressable>
<Pressable style={styles.selectButton} onPress={() => setPickerMode('plant')}>
<Text style={styles.selectButtonText} numberOfLines={1}>{selectedPlantLabel}</Text>
</Pressable>
<TextInput
placeholder="Notizen (optional)"
placeholderTextColor="#9ca3af"
style={[styles.searchInput, styles.multilineInput]}
value={notesInput}
onChangeText={setNotesInput}
multiline
/>
{pickerMode === 'customer' ? (
<View style={styles.inlinePickerBox}>
<Text style={styles.inlinePickerTitle}>Kunde auswählen</Text>
<TextInput
placeholder="Kunden suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={customerSearch}
onChangeText={setCustomerSearch}
/>
<ScrollView style={styles.pickerList}>
<Pressable
style={styles.pickerRow}
onPress={() => {
setSelectedCustomerId(null);
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle}>Keine Auswahl</Text>
</Pressable>
{filteredCustomerOptions.map((customer) => (
<Pressable
key={String(customer.id)}
style={styles.pickerRow}
onPress={() => {
setSelectedCustomerId(Number(customer.id));
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle} numberOfLines={1}>{customer.name}</Text>
<Text style={styles.pickerRowMeta} numberOfLines={1}>
{customer.customerNumber ? `Nr. ${customer.customerNumber}` : `ID ${customer.id}`}
</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
{pickerMode === 'plant' ? (
<View style={styles.inlinePickerBox}>
<Text style={styles.inlinePickerTitle}>Objekt auswählen</Text>
<TextInput
placeholder="Objekte suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={plantSearch}
onChangeText={setPlantSearch}
/>
<ScrollView style={styles.pickerList}>
<Pressable
style={styles.pickerRow}
onPress={() => {
setSelectedPlantId(null);
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle}>Keine Auswahl</Text>
</Pressable>
{filteredPlantOptions.map((plant) => (
<Pressable
key={String(plant.id)}
style={styles.pickerRow}
onPress={() => {
setSelectedPlantId(Number(plant.id));
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle} numberOfLines={1}>{plant.name}</Text>
<Text style={styles.pickerRowMeta} numberOfLines={1}>ID {plant.id}</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
{createError ? <Text style={styles.error}>{createError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={onCreateProject}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#ffffff' },
searchWrap: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
backgroundColor: '#ffffff',
gap: 8,
},
searchInput: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
list: { flex: 1, backgroundColor: '#ffffff' },
listContent: { paddingBottom: 96 },
row: {
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
backgroundColor: '#ffffff',
},
rowHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
},
rowTitle: { flex: 1, color: '#111827', fontSize: 15, fontWeight: '600' },
archivedBadge: {
color: '#3d7a30',
fontSize: 11,
fontWeight: '600',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 3,
overflow: 'hidden',
},
rowSubtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 },
phaseText: { color: '#6b7280', fontSize: 12, marginTop: 2 },
toggleButton: {
alignSelf: 'flex-start',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
},
toggleButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
toggleButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' },
toggleButtonTextActive: { color: '#3d7a30' },
loadingBox: { paddingVertical: 20, alignItems: 'center', gap: 8 },
loadingText: { color: '#6b7280' },
empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 20 },
error: { color: '#dc2626', fontSize: 13, paddingHorizontal: 16, paddingTop: 10 },
fab: {
position: 'absolute',
right: 18,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#111827',
shadowOpacity: 0.25,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 5,
},
fabText: { color: '#ffffff', fontSize: 30, lineHeight: 30, marginTop: -2 },
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(17, 24, 39, 0.45)',
justifyContent: 'center',
padding: 20,
},
modalKeyboardWrap: { width: '100%' },
modalCard: { backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 10 },
modalTitle: { color: '#111827', fontSize: 18, fontWeight: '700' },
multilineInput: { minHeight: 92, textAlignVertical: 'top' },
selectButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#ffffff',
},
selectButtonText: { color: '#111827', fontSize: 15 },
inlinePickerBox: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 10,
gap: 8,
backgroundColor: '#fafafa',
},
inlinePickerTitle: { color: '#111827', fontSize: 14, fontWeight: '700' },
pickerList: { maxHeight: 220 },
pickerRow: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 8,
backgroundColor: '#ffffff',
},
pickerRowTitle: { color: '#111827', fontSize: 14, fontWeight: '600' },
pickerRowMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 4 },
secondaryButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
minHeight: 40,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
},
secondaryButtonText: { color: '#374151', fontWeight: '600' },
primaryButton: {
borderRadius: 10,
minHeight: 40,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: PRIMARY,
},
primaryButtonText: { color: '#ffffff', fontWeight: '700' },
buttonDisabled: { opacity: 0.6 },
});

582
mobile/app/(tabs)/tasks.tsx Normal file
View File

@@ -0,0 +1,582 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { createTask, fetchTasks, fetchTenantProfiles, Task, TaskStatus, updateTask } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const STATUSES: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
const PRIMARY = '#69c350';
function normalizeStatus(status: unknown): TaskStatus {
if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status;
return 'Offen';
}
function getTaskAssigneeId(task: Task): string | null {
return (task.userId || task.user_id || task.profile || null) as string | null;
}
export default function TasksScreen() {
const { token, user, activeTenantId } = useAuth();
const params = useLocalSearchParams<{ action?: string | string[] }>();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [profiles, setProfiles] = useState<{ id: string; label: string }[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<'Alle' | TaskStatus>('Alle');
const [showCompleted, setShowCompleted] = useState(false);
const [showSearchPanel, setShowSearchPanel] = useState(false);
const [showFilterPanel, setShowFilterPanel] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newTaskName, setNewTaskName] = useState('');
const [newTaskDescription, setNewTaskDescription] = useState('');
const handledActionRef = useRef<string | null>(null);
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const incomingAction = useMemo(() => {
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
return String(raw || '').toLowerCase();
}, [params.action]);
const filteredTasks = useMemo(() => {
const needle = search.trim().toLowerCase();
return tasks
.filter((task) => {
const status = normalizeStatus(task.categorie);
if (!showCompleted && status === 'Abgeschlossen') return false;
const statusMatch = statusFilter === 'Alle' || status === statusFilter;
const textMatch =
!needle ||
[task.name, task.description, task.categorie].some((value) =>
String(value || '').toLowerCase().includes(needle)
);
return statusMatch && textMatch;
})
.sort((a, b) => Number(a.id) - Number(b.id));
}, [search, showCompleted, statusFilter, tasks]);
function getAssigneeLabel(task: Task): string {
const assigneeId = getTaskAssigneeId(task);
if (!assigneeId) return '-';
return profiles.find((profile) => profile.id === assigneeId)?.label || assigneeId;
}
const loadTasks = useCallback(
async (showSpinner = true) => {
if (!token) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [taskRows, profileRows] = await Promise.all([fetchTasks(token), fetchTenantProfiles(token)]);
setTasks(taskRows || []);
setProfiles(
(profileRows || [])
.map((profile) => {
const id = profile.user_id || (profile.id ? String(profile.id) : null);
const label = profile.full_name || profile.fullName || profile.email || id;
return id ? { id: String(id), label: String(label || id) } : null;
})
.filter((value): value is { id: string; label: string } => Boolean(value))
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Aufgaben konnten nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[token]
);
useEffect(() => {
if (!token || !activeTenantId) return;
void loadTasks(true);
}, [token, activeTenantId, loadTasks]);
useEffect(() => {
if (incomingAction !== 'create') return;
if (handledActionRef.current === incomingAction) return;
handledActionRef.current = incomingAction;
setCreateModalOpen(true);
}, [incomingAction]);
async function onRefresh() {
if (!token) return;
setRefreshing(true);
await loadTasks(false);
}
function closeCreateModal() {
setCreateModalOpen(false);
setCreateError(null);
setNewTaskName('');
setNewTaskDescription('');
handledActionRef.current = null;
}
async function onCreateTask() {
if (!token) return;
const name = newTaskName.trim();
if (!name) {
setCreateError('Bitte einen Aufgabennamen eingeben.');
return;
}
setSaving(true);
setCreateError(null);
try {
await createTask(token, {
name,
description: newTaskDescription.trim() || null,
categorie: 'Offen',
userId: currentUserId,
});
closeCreateModal();
await loadTasks(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}
async function setTaskStatus(task: Task, status: TaskStatus) {
if (!token || !task?.id) return;
if (normalizeStatus(task.categorie) === status) return;
setUpdatingTaskId(Number(task.id));
setError(null);
try {
await updateTask(token, Number(task.id), { categorie: status });
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, categorie: status } : item)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Status konnte nicht gesetzt werden.');
} finally {
setUpdatingTaskId(null);
}
}
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.topActions}>
<Pressable
style={[styles.topActionButton, showSearchPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowSearchPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showSearchPanel ? styles.topActionTextActive : null]}>Suche</Text>
</Pressable>
<Pressable
style={[styles.topActionButton, showFilterPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowFilterPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showFilterPanel ? styles.topActionTextActive : null]}>Filter</Text>
</Pressable>
</View>
{showSearchPanel ? (
<View style={styles.panel}>
<TextInput
placeholder="Suche"
placeholderTextColor="#9ca3af"
style={styles.input}
value={search}
onChangeText={setSearch}
/>
</View>
) : null}
{showFilterPanel ? (
<View style={styles.panel}>
<View style={styles.filterRow}>
{(['Alle', 'Offen', 'In Bearbeitung'] as const).map((status) => (
<Pressable
key={status}
style={[styles.filterChip, statusFilter === status ? styles.filterChipActive : null]}
onPress={() => setStatusFilter(status)}>
<Text
style={[
styles.filterChipText,
statusFilter === status ? styles.filterChipTextActive : null,
]}>
{status}
</Text>
</Pressable>
))}
<Pressable
style={[styles.filterChip, showCompleted ? styles.filterChipActive : null]}
onPress={() => setShowCompleted((prev) => !prev)}>
<Text style={[styles.filterChipText, showCompleted ? styles.filterChipTextActive : null]}>
Abgeschlossene anzeigen
</Text>
</Pressable>
</View>
</View>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Aufgaben werden geladen...</Text>
</View>
) : null}
{!loading && filteredTasks.length === 0 ? (
<Text style={styles.empty}>Keine Aufgaben gefunden.</Text>
) : null}
{!loading &&
filteredTasks.map((task) => {
const status = normalizeStatus(task.categorie);
const isUpdating = updatingTaskId === Number(task.id);
return (
<View key={String(task.id)} style={styles.taskCard}>
<View style={styles.taskHeader}>
<Text style={styles.taskTitle} numberOfLines={2}>
{task.name}
</Text>
<Text style={styles.statusBadge}>{status}</Text>
</View>
{task.description ? (
<Text style={styles.taskDescription} numberOfLines={3}>
{task.description}
</Text>
) : null}
<Text style={styles.taskMeta}>Zuweisung: {getAssigneeLabel(task)}</Text>
<View style={styles.actionRow}>
{STATUSES.map((nextStatus) => (
<Pressable
key={nextStatus}
style={[
styles.actionButton,
nextStatus === status ? styles.actionButtonActive : null,
isUpdating ? styles.buttonDisabled : null,
]}
onPress={() => setTaskStatus(task, nextStatus)}
disabled={isUpdating || nextStatus === status}>
<Text style={styles.actionButtonText}>{nextStatus}</Text>
</Pressable>
))}
</View>
</View>
);
})}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateModalOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<Modal visible={createModalOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neue Aufgabe</Text>
<TextInput
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={styles.input}
value={newTaskName}
onChangeText={setNewTaskName}
/>
<TextInput
placeholder="Beschreibung (optional)"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.inputMultiline]}
multiline
value={newTaskDescription}
onChangeText={setNewTaskDescription}
/>
{createError ? <Text style={styles.error}>{createError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={onCreateTask}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#f9fafb',
},
container: {
padding: 16,
gap: 12,
paddingBottom: 96,
},
topActions: {
flexDirection: 'row',
gap: 8,
},
topActionButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#ffffff',
},
topActionButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
topActionText: {
color: '#374151',
fontWeight: '600',
},
topActionTextActive: {
color: '#3d7a30',
},
panel: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
inputMultiline: {
minHeight: 72,
textAlignVertical: 'top',
},
filterRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
filterChip: {
borderRadius: 999,
borderWidth: 1,
borderColor: '#d1d5db',
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
},
filterChipActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
filterChipText: {
color: '#374151',
fontSize: 13,
fontWeight: '500',
},
filterChipTextActive: {
color: '#3d7a30',
},
error: {
color: '#dc2626',
fontSize: 13,
},
loadingBox: {
padding: 16,
alignItems: 'center',
gap: 8,
},
loadingText: {
color: '#6b7280',
},
empty: {
color: '#6b7280',
textAlign: 'center',
paddingVertical: 16,
},
taskCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
},
taskHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 8,
},
taskTitle: {
flex: 1,
color: '#111827',
fontSize: 16,
fontWeight: '600',
},
statusBadge: {
color: '#3d7a30',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 12,
overflow: 'hidden',
},
taskDescription: {
color: '#374151',
fontSize: 14,
},
taskMeta: {
color: '#6b7280',
fontSize: 12,
},
actionRow: {
flexDirection: 'row',
gap: 8,
flexWrap: 'wrap',
},
actionButton: {
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
paddingHorizontal: 10,
paddingVertical: 7,
},
actionButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
actionButtonText: {
color: '#1f2937',
fontSize: 12,
fontWeight: '500',
},
fab: {
position: 'absolute',
right: 18,
bottom: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
elevation: 4,
shadowColor: '#111827',
shadowOpacity: 0.18,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
},
fabText: {
color: '#ffffff',
fontSize: 30,
lineHeight: 30,
fontWeight: '500',
marginTop: -1,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'center',
padding: 16,
},
modalKeyboardWrap: {
width: '100%',
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 14,
gap: 10,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
marginTop: 2,
},
secondaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e5e7eb',
},
secondaryButtonText: {
color: '#111827',
fontWeight: '600',
},
primaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
},
});

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import {
createStaffTimeEvent,
@@ -47,6 +48,7 @@ function getTypeLabel(type: string): string {
export default function TimeTrackingScreen() {
const { token, user } = useAuth();
const params = useLocalSearchParams<{ action?: string | string[] }>();
const [entries, setEntries] = useState<StaffTimeSpan[]>([]);
const [loading, setLoading] = useState(true);
@@ -55,8 +57,13 @@ export default function TimeTrackingScreen() {
const [error, setError] = useState<string | null>(null);
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const handledActionRef = useRef<string | null>(null);
const active = useMemo(() => entries.find((entry) => !entry.stopped_at) || null, [entries]);
const incomingAction = useMemo(() => {
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
return String(raw || '').toLowerCase();
}, [params.action]);
const load = useCallback(
async (showSpinner = true) => {
@@ -87,7 +94,7 @@ export default function TimeTrackingScreen() {
await load(false);
}
async function onStart() {
const onStart = useCallback(async () => {
if (!token || !currentUserId) return;
setActionLoading(true);
setError(null);
@@ -105,9 +112,9 @@ export default function TimeTrackingScreen() {
} finally {
setActionLoading(false);
}
}
}, [currentUserId, token, load]);
async function onStop() {
const onStop = useCallback(async () => {
if (!token || !currentUserId || !active) return;
setActionLoading(true);
setError(null);
@@ -124,7 +131,7 @@ export default function TimeTrackingScreen() {
} finally {
setActionLoading(false);
}
}
}, [active, currentUserId, token, load]);
async function onSubmit(entry: StaffTimeSpan) {
if (!token || !entry.eventIds?.length) return;
@@ -141,6 +148,57 @@ export default function TimeTrackingScreen() {
}
}
const onSubmitAll = useCallback(async () => {
if (!token) return;
const submitCandidates = entries.filter(
(entry) => (entry.state === 'draft' || entry.state === 'factual') && !!entry.stopped_at && !!entry.eventIds?.length
);
if (submitCandidates.length === 0) return;
setActionLoading(true);
setError(null);
try {
for (const entry of submitCandidates) {
await submitStaffTime(token, entry.eventIds);
}
await load(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Einreichen fehlgeschlagen.');
} finally {
setActionLoading(false);
}
}, [entries, load, token]);
useEffect(() => {
if (!token || !currentUserId) return;
if (!incomingAction) return;
if (handledActionRef.current === incomingAction) return;
if (incomingAction === 'start' && !active) {
handledActionRef.current = incomingAction;
void onStart().finally(() => router.replace('/(tabs)/time'));
return;
}
if (incomingAction === 'stop' && active) {
handledActionRef.current = incomingAction;
void onStop().finally(() => router.replace('/(tabs)/time'));
return;
}
if (incomingAction === 'submit') {
handledActionRef.current = incomingAction;
void onSubmitAll().finally(() => router.replace('/(tabs)/time'));
return;
}
handledActionRef.current = incomingAction;
void router.replace('/(tabs)/time');
}, [active, currentUserId, incomingAction, onStart, onStop, onSubmitAll, token]);
return (
<ScrollView
contentContainerStyle={styles.container}
@@ -148,7 +206,7 @@ export default function TimeTrackingScreen() {
<View style={styles.statusCard}>
<Text style={styles.statusLabel}>Aktive Zeit</Text>
<Text style={styles.statusValue}>
{active ? `Laeuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
{active ? `Läuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
</Text>
<View style={styles.statusActions}>
@@ -179,7 +237,7 @@ export default function TimeTrackingScreen() {
</View>
) : null}
{!loading && entries.length === 0 ? <Text style={styles.empty}>Keine Zeiteintraege vorhanden.</Text> : null}
{!loading && entries.length === 0 ? <Text style={styles.empty}>Keine Zeiteinträge vorhanden.</Text> : null}
{!loading &&
entries.map((entry) => {
@@ -194,7 +252,7 @@ export default function TimeTrackingScreen() {
<Text style={styles.entryTime}>Start: {formatDateTime(entry.started_at)}</Text>
<Text style={styles.entryTime}>
Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'laeuft...'}
Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'läuft...'}
</Text>
<Text style={styles.entryTime}>Dauer: {formatDuration(entry.duration_minutes)}</Text>

View File

@@ -10,8 +10,108 @@ export default function RootLayout() {
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ title: 'Login', headerBackVisible: false }} />
<Stack.Screen name="tenant-select" options={{ title: 'Tenant auswaehlen', headerBackVisible: false }} />
<Stack.Screen name="tenant-select" options={{ title: 'Tenant auswählen', headerBackVisible: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="project/[id]"
options={{
title: 'Projekt',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/account"
options={{
title: 'Konto',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/settings"
options={{
title: 'Einstellungen',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/wiki"
options={{
title: 'Wiki',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/customers"
options={{
title: 'Kunden',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/customer/[id]"
options={{
title: 'Kunde',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/plants"
options={{
title: 'Objekte',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/plant/[id]"
options={{
title: 'Objekt',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/inventory"
options={{
title: 'Kundeninventar',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/nimbot"
options={{
title: 'Nimbot M2',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
</Stack>
<StatusBar style="dark" />
</AuthProvider>

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { Redirect, router } from 'expo-router';
import { useCallback, useState } from 'react';
import { Redirect, router, useFocusEffect } from 'expo-router';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
@@ -11,22 +12,100 @@ import {
View,
} from 'react-native';
import { API_BASE_URL } from '@/src/config/env';
import {
getApiBaseUrlSync,
hydrateApiBaseUrl,
isServerSetupDone,
markServerSetupDone,
setApiBaseUrl as persistApiBaseUrl,
} from '@/src/lib/server-config';
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function isValidServerUrl(value: string): boolean {
return /^https?:\/\/.+/i.test(value.trim());
}
export default function LoginScreen() {
const { token, requiresTenantSelection, login } = useAuth();
const storageInfo = useTokenStorageInfo();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
const [showServerModal, setShowServerModal] = useState(false);
const [isServerSetupRequired, setIsServerSetupRequired] = useState(false);
const [isServerSaving, setIsServerSaving] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useFocusEffect(
useCallback(() => {
let active = true;
async function refreshApiBase() {
const current = await hydrateApiBaseUrl();
const setupDone = await isServerSetupDone();
if (!active) return;
setApiBaseUrl(current);
setServerInput(current);
setIsServerSetupRequired(!setupDone);
setShowServerModal(!setupDone);
}
void refreshApiBase();
return () => {
active = false;
};
}, [])
);
if (token) {
return <Redirect href={requiresTenantSelection ? '/tenant-select' : '/(tabs)'} />;
}
async function applyDefaultServer() {
setIsServerSaving(true);
setServerError(null);
try {
await markServerSetupDone();
setIsServerSetupRequired(false);
setShowServerModal(false);
setApiBaseUrl(getApiBaseUrlSync());
} catch (err) {
setServerError(err instanceof Error ? err.message : 'Server-Einstellung konnte nicht gespeichert werden.');
} finally {
setIsServerSaving(false);
}
}
async function saveCustomServer() {
setServerError(null);
const value = serverInput.trim();
if (!isValidServerUrl(value)) {
setServerError('Bitte eine gültige URL mit http:// oder https:// eingeben.');
return;
}
setIsServerSaving(true);
try {
const normalized = await persistApiBaseUrl(value);
setApiBaseUrl(normalized);
setServerInput(normalized);
setIsServerSetupRequired(false);
setShowServerModal(false);
} catch (err) {
setServerError(err instanceof Error ? err.message : 'Server-Einstellung konnte nicht gespeichert werden.');
} finally {
setIsServerSaving(false);
}
}
async function onSubmit() {
setIsSubmitting(true);
setError(null);
@@ -76,15 +155,57 @@ export default function LoginScreen() {
<Pressable
style={[styles.button, isSubmitting ? styles.buttonDisabled : null]}
onPress={onSubmit}
disabled={isSubmitting || !email || !password}>
disabled={isSubmitting || isServerSetupRequired || !email || !password}>
{isSubmitting ? <ActivityIndicator color="#ffffff" /> : <Text style={styles.buttonText}>Anmelden</Text>}
</Pressable>
<Pressable style={styles.serverLink} onPress={() => setShowServerModal(true)}>
<Text style={styles.serverLinkText}>Eigenen Server festlegen</Text>
</Pressable>
<View style={styles.metaBox}>
<Text style={styles.metaText}>API: {API_BASE_URL}</Text>
<Text style={styles.metaText}>API: {apiBaseUrl}</Text>
<Text style={styles.metaText}>Token Storage: {storageInfo.mode}</Text>
</View>
</View>
<Modal visible={showServerModal} transparent animationType="fade" onRequestClose={() => {}}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Server-Instanz festlegen</Text>
<Text style={styles.modalText}>
Vor dem ersten Login bitte Server wählen. Standard verwenden oder eigene Instanz hinterlegen.
</Text>
<TextInput
value={serverInput}
onChangeText={setServerInput}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="https://dein-server.tld"
placeholderTextColor="#9ca3af"
style={styles.input}
/>
{serverError ? <Text style={styles.error}>{serverError}</Text> : null}
<Pressable
style={[styles.modalPrimaryButton, isServerSaving ? styles.buttonDisabled : null]}
onPress={saveCustomServer}
disabled={isServerSaving}>
<Text style={styles.modalPrimaryText}>{isServerSaving ? 'Speichern...' : 'Eigene Instanz speichern'}</Text>
</Pressable>
<Pressable
style={[styles.modalSecondaryButton, isServerSaving ? styles.buttonDisabled : null]}
onPress={applyDefaultServer}
disabled={isServerSaving}>
<Text style={styles.modalSecondaryText}>Standardserver verwenden</Text>
</Pressable>
</View>
</View>
</Modal>
</KeyboardAvoidingView>
);
}
@@ -132,7 +253,7 @@ const styles = StyleSheet.create({
},
button: {
marginTop: 6,
backgroundColor: '#16a34a',
backgroundColor: PRIMARY,
borderRadius: 10,
minHeight: 44,
alignItems: 'center',
@@ -157,8 +278,66 @@ const styles = StyleSheet.create({
borderTopColor: '#e5e7eb',
gap: 4,
},
serverLink: {
marginTop: 2,
alignSelf: 'flex-start',
paddingVertical: 4,
},
serverLinkText: {
color: PRIMARY,
fontSize: 13,
fontWeight: '600',
},
metaText: {
fontSize: 12,
color: '#6b7280',
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(17, 24, 39, 0.45)',
justifyContent: 'center',
padding: 20,
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 16,
gap: 10,
},
modalTitle: {
color: '#111827',
fontSize: 18,
fontWeight: '700',
},
modalText: {
color: '#6b7280',
fontSize: 13,
lineHeight: 18,
},
modalPrimaryButton: {
minHeight: 42,
borderRadius: 10,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
modalPrimaryText: {
color: '#ffffff',
fontWeight: '700',
fontSize: 14,
},
modalSecondaryButton: {
minHeight: 42,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
},
modalSecondaryText: {
color: '#374151',
fontWeight: '600',
fontSize: 14,
},
});

View File

@@ -1,15 +1,31 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Redirect, router } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useAuth } from '@/src/providers/auth-provider';
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
export default function TenantSelectScreen() {
const { token, tenants, activeTenantId, requiresTenantSelection, switchTenant } = useAuth();
const { token, tenants, activeTenantId, requiresTenantSelection, switchTenant, user, logout } = useAuth();
const storageInfo = useTokenStorageInfo();
const [switchingTenantId, setSwitchingTenantId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const filteredTenants = useMemo(() => {
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return tenants;
return tenants.filter((tenant) => {
const haystack = `${String(tenant.name || '').toLowerCase()} ${String(tenant.id || '').toLowerCase()} ${
String(tenant.short || '').toLowerCase()
}`;
return terms.every((term) => haystack.includes(term));
});
}, [search, tenants]);
if (!token) {
return <Redirect href="/login" />;
}
@@ -32,14 +48,34 @@ export default function TenantSelectScreen() {
}
}
async function onLogout() {
setSwitchingTenantId(null);
await logout();
router.replace('/login');
}
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Tenant auswaehlen</Text>
<Text style={styles.subtitle}>Bitte waehle den Tenant fuer deine Session.</Text>
<View style={styles.headerCard}>
<Text style={styles.title}>Tenant auswählen</Text>
<Text style={styles.subtitle}>Wähle den Mandanten für diese Session.</Text>
<Text style={styles.meta}>User: {String(user?.email || user?.id || 'unbekannt')}</Text>
<Text style={styles.meta}>Storage: {storageInfo.mode}</Text>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{tenants.map((tenant) => {
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Tenant suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
/>
{filteredTenants.length === 0 ? <Text style={styles.empty}>Keine passenden Tenants gefunden.</Text> : null}
{filteredTenants.map((tenant) => {
const tenantId = Number(tenant.id);
const isBusy = switchingTenantId === tenantId;
@@ -49,14 +85,20 @@ export default function TenantSelectScreen() {
style={[styles.tenantButton, isBusy ? styles.tenantButtonDisabled : null]}
onPress={() => onSelectTenant(tenantId)}
disabled={switchingTenantId !== null}>
<View>
<Text style={styles.tenantName}>{tenant.name}</Text>
<View style={styles.tenantInfo}>
<Text style={styles.tenantName} numberOfLines={1}>
{tenant.name}
</Text>
<Text style={styles.tenantMeta}>ID: {tenantId}</Text>
</View>
{isBusy ? <ActivityIndicator /> : <Text style={styles.tenantAction}>Auswaehlen</Text>}
{isBusy ? <ActivityIndicator color={PRIMARY} /> : <Text style={styles.tenantAction}>Auswählen</Text>}
</Pressable>
);
})}
<Pressable style={styles.logoutButton} onPress={onLogout} disabled={switchingTenantId !== null}>
<Text style={styles.logoutText}>Anderen Nutzer anmelden</Text>
</Pressable>
</ScrollView>
);
}
@@ -65,21 +107,43 @@ const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 16,
gap: 10,
gap: 12,
backgroundColor: '#f9fafb',
},
headerCard: {
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#ffffff',
padding: 14,
gap: 4,
},
title: {
fontSize: 24,
fontSize: 22,
fontWeight: '700',
color: '#111827',
},
subtitle: {
color: '#6b7280',
marginBottom: 8,
marginBottom: 2,
},
meta: {
color: '#6b7280',
fontSize: 12,
},
error: {
color: '#dc2626',
marginBottom: 8,
fontSize: 13,
},
searchInput: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
tenantButton: {
borderRadius: 12,
@@ -94,6 +158,11 @@ const styles = StyleSheet.create({
tenantButtonDisabled: {
opacity: 0.6,
},
tenantInfo: {
flex: 1,
minWidth: 0,
paddingRight: 10,
},
tenantName: {
fontSize: 16,
fontWeight: '600',
@@ -104,7 +173,27 @@ const styles = StyleSheet.create({
marginTop: 4,
},
tenantAction: {
color: '#2563eb',
color: PRIMARY,
fontWeight: '600',
},
empty: {
color: '#6b7280',
fontSize: 13,
textAlign: 'center',
paddingVertical: 8,
},
logoutButton: {
marginTop: 4,
minHeight: 42,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
alignItems: 'center',
justifyContent: 'center',
},
logoutText: {
color: '#374151',
fontWeight: '600',
},
});

View File

@@ -13,10 +13,13 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "^15.0.8",
@@ -28,11 +31,13 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-ble-plx": "^3.5.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
@@ -6066,6 +6071,26 @@
"react-native": "*"
}
},
"node_modules/expo-camera": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -6080,6 +6105,15 @@
"react-native": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -6130,6 +6164,27 @@
}
}
},
"node_modules/expo-image-loader": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
"integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
"integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-keep-awake": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
@@ -10274,6 +10329,19 @@
}
}
},
"node_modules/react-native-ble-plx": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/react-native-ble-plx/-/react-native-ble-plx-3.5.1.tgz",
"integrity": "sha512-SxksmrUt9jG6DOarrrdkb5c/HBLSfZOKauo/9VQSSi3WJA4bmF78GkrtXrgSoGNk0m1ksacFTjB5DuL39xZq/g==",
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-gesture-handler": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
@@ -10384,6 +10452,20 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native-webview": {
"version": "13.16.0",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz",
"integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0",
"invariant": "2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-worklets": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",

View File

@@ -3,12 +3,15 @@
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"start": "expo start --dev-client --host lan",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios",
"ios:device": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios --device",
"web": "expo start --web",
"lint": "expo lint"
"lint": "expo lint",
"build:ios:dev": "eas build --profile development --platform ios",
"build:ios:preview": "eas build --profile preview --platform ios"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
@@ -16,10 +19,13 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "^15.0.8",
@@ -31,11 +37,13 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-ble-plx": "^3.5.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {

View File

@@ -1,2 +1,2 @@
export const API_BASE_URL =
export const DEFAULT_API_BASE_URL =
process.env.EXPO_PUBLIC_API_BASE?.replace(/\/$/, '') || 'http://localhost:3100';

View File

@@ -1,4 +1,4 @@
import { API_BASE_URL } from '@/src/config/env';
import { getApiBaseUrlSync } from '@/src/lib/server-config';
export type Tenant = {
id: number;
@@ -17,6 +17,9 @@ export type Task = {
userId?: string | null;
user_id?: string | null;
profile?: string | null;
project?: number | { id?: number; name?: string } | null;
customer?: number | { id?: number; name?: string } | null;
plant?: number | { id?: number; name?: string } | null;
archived?: boolean;
[key: string]: unknown;
};
@@ -42,6 +45,73 @@ export type StaffTimeSpan = {
description: string;
};
export type Project = {
id: number;
name: string;
notes?: string | null;
projectNumber?: string | null;
archived?: boolean;
[key: string]: unknown;
};
export type ProjectFile = {
id: string;
name?: string | null;
path?: string | null;
project?: number | { id?: number; name?: string };
customer?: number | { id?: number; name?: string };
plant?: number | { id?: number; name?: string };
createddocument?: number | { id?: number; documentNumber?: string };
mimeType?: string | null;
url?: string;
archived?: boolean;
[key: string]: unknown;
};
export type Customer = {
id: number;
name: string;
customerNumber?: string | null;
notes?: string | null;
archived?: boolean;
[key: string]: unknown;
};
export type Plant = {
id: number;
name: string;
description?: string | null;
customer?: number | { id?: number; name?: string };
archived?: boolean;
[key: string]: unknown;
};
export type CustomerInventoryItem = {
id: number;
name: string;
customer?: number | { id?: number; name?: string } | null;
customerInventoryId?: string | null;
serialNumber?: string | null;
description?: string | null;
manufacturer?: string | null;
manufacturerNumber?: string | null;
quantity?: number | null;
archived?: boolean;
[key: string]: unknown;
};
export type CreatedDocument = {
id: number;
documentNumber?: string | null;
title?: string | null;
type?: string | null;
state?: string | null;
documentDate?: string | null;
customer?: number | { id?: number; name?: string };
archived?: boolean;
[key: string]: unknown;
};
export type MeResponse = {
user: {
id: string;
@@ -55,6 +125,52 @@ export type MeResponse = {
permissions: string[];
};
export type EncodedLabelRow = {
dataType: 'pixels' | 'void' | 'check';
rowNumber: number;
repeat: number;
rowData?: Uint8Array | number[] | Record<string, number>;
blackPixelsCount: number;
};
export type EncodedLabelImage = {
cols: number;
rows: number;
rowsData: EncodedLabelRow[];
};
export type PrintLabelResponse = {
encoded: EncodedLabelImage;
base64?: string;
};
export type WikiTreeItem = {
id: string;
parentId?: string | null;
title: string;
isFolder?: boolean;
isVirtual?: boolean;
sortOrder?: number;
entityType?: string | null;
entityId?: number | null;
entityUuid?: string | null;
updatedAt?: string;
[key: string]: unknown;
};
export type WikiPage = {
id: string;
title: string;
content?: unknown;
parentId?: string | null;
isFolder?: boolean;
entityType?: string | null;
entityId?: number | null;
entityUuid?: string | null;
updatedAt?: string;
[key: string]: unknown;
};
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
token?: string | null;
@@ -67,7 +183,7 @@ function buildUrl(path: string): string {
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}`;
return `${getApiBaseUrlSync()}${normalizedPath}`;
}
async function parseJson(response: Response): Promise<unknown> {
@@ -109,6 +225,23 @@ export async function checkBackendHealth(): Promise<{ status: string; [key: stri
return apiRequest<{ status: string; [key: string]: unknown }>('/health');
}
export async function renderPrintLabel(
token: string,
context: Record<string, unknown>,
width = 584,
height = 354
): Promise<PrintLabelResponse> {
return apiRequest<PrintLabelResponse>('/api/print/label', {
method: 'POST',
token,
body: {
context,
width,
height,
},
});
}
export async function loginWithEmailPassword(email: string, password: string): Promise<string> {
const payload = await apiRequest<{ token?: string }>('/auth/login', {
method: 'POST',
@@ -154,6 +287,9 @@ export async function createTask(
description?: string | null;
categorie?: TaskStatus;
userId?: string | null;
project?: number | null;
customer?: number | null;
plant?: number | null;
}
): Promise<Task> {
return apiRequest<Task>('/api/resource/tasks', {
@@ -258,3 +394,544 @@ export async function rejectStaffTime(
body: { eventIds, employeeUserId, reason },
});
}
export async function fetchProjects(token: string, includeArchived = false): Promise<Project[]> {
const projects = await apiRequest<Project[]>('/api/resource/projects', { token });
if (includeArchived) return projects || [];
return (projects || []).filter((project) => !project.archived);
}
export async function createProject(
token: string,
payload: {
name: string;
projectNumber?: string | null;
customer?: number | null;
plant?: number | null;
notes?: string | null;
}
): Promise<Project> {
return apiRequest<Project>('/api/resource/projects', {
method: 'POST',
token,
body: payload,
});
}
export async function fetchCustomers(token: string, includeArchived = false): Promise<Customer[]> {
const customers = await apiRequest<Customer[]>('/api/resource/customers', { token });
if (includeArchived) return customers || [];
return (customers || []).filter((customer) => !customer.archived);
}
export async function createCustomer(
token: string,
payload: {
name: string;
customerNumber?: string | null;
notes?: string | null;
}
): Promise<Customer> {
return apiRequest<Customer>('/api/resource/customers', {
method: 'POST',
token,
body: payload,
});
}
export async function fetchCustomerById(token: string, customerId: number): Promise<Customer> {
return apiRequest<Customer>(`/api/resource/customers/${customerId}`, { token });
}
function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null {
const rawCustomer = item.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
export async function fetchCustomerInventoryItems(
token: string,
customerId: number,
includeArchived = false
): Promise<CustomerInventoryItem[]> {
const rows = await apiRequest<CustomerInventoryItem[]>('/api/resource/customerinventoryitems', { token });
return (rows || []).filter((item) => {
if (!includeArchived && item.archived) return false;
return resolveCustomerIdFromCustomerInventoryItem(item) === Number(customerId);
});
}
export async function fetchAllCustomerInventoryItems(
token: string,
includeArchived = false
): Promise<CustomerInventoryItem[]> {
const rows = await apiRequest<CustomerInventoryItem[]>('/api/resource/customerinventoryitems', { token });
if (includeArchived) return rows || [];
return (rows || []).filter((item) => !item.archived);
}
export async function createCustomerInventoryItem(
token: string,
payload: {
customer: number;
name: string;
customerInventoryId?: string | null;
serialNumber?: string | null;
description?: string | null;
quantity?: number | null;
}
): Promise<CustomerInventoryItem> {
const autoInventoryId = `MOB-${Date.now()}`;
return apiRequest<CustomerInventoryItem>('/api/resource/customerinventoryitems', {
method: 'POST',
token,
body: {
customer: payload.customer,
name: payload.name,
customerInventoryId: payload.customerInventoryId?.trim() || autoInventoryId,
serialNumber: payload.serialNumber?.trim() || null,
description: payload.description?.trim() || null,
quantity: Number.isFinite(Number(payload.quantity)) ? Number(payload.quantity) : 1,
},
});
}
export async function fetchPlants(token: string, includeArchived = false): Promise<Plant[]> {
const plants = await apiRequest<Array<Plant & { description?: unknown }>>('/api/resource/plants', { token });
const normalized = (plants || []).map((plant) => {
const legacyDescription = typeof plant.description === 'string'
? plant.description
: (plant.description && typeof plant.description === 'object' && 'text' in (plant.description as Record<string, unknown>)
? String((plant.description as Record<string, unknown>).text || '')
: null);
return {
...plant,
description: legacyDescription || null,
} as Plant;
});
if (includeArchived) return normalized;
return normalized.filter((plant) => !plant.archived);
}
export async function createPlant(
token: string,
payload: {
name: string;
description?: string | null;
customer?: number | null;
}
): Promise<Plant> {
return apiRequest<Plant>('/api/resource/plants', {
method: 'POST',
token,
body: {
name: payload.name,
customer: payload.customer ?? null,
description: {
text: payload.description?.trim() || '',
html: '',
json: [],
},
},
});
}
export async function fetchPlantById(token: string, plantId: number): Promise<Plant> {
return apiRequest<Plant>(`/api/resource/plants/${plantId}`, { token });
}
function toQueryString(params: Record<string, unknown>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
searchParams.append(key, String(value));
});
const query = searchParams.toString();
return query ? `?${query}` : '';
}
export async function fetchWikiTree(
token: string,
filters: {
entityType?: string;
entityId?: number | null;
entityUuid?: string | null;
} = {}
): Promise<WikiTreeItem[]> {
const query = toQueryString({
entityType: filters.entityType,
entityId: filters.entityId,
entityUuid: filters.entityUuid,
});
return apiRequest<WikiTreeItem[]>(`/api/wiki/tree${query}`, { token });
}
export async function fetchWikiPageById(token: string, pageId: string): Promise<WikiPage> {
return apiRequest<WikiPage>(`/api/wiki/${encodeURIComponent(pageId)}`, { token });
}
export async function createWikiPage(
token: string,
payload: {
title: string;
parentId?: string | null;
isFolder?: boolean;
entityType?: string;
entityId?: number | null;
entityUuid?: string | null;
}
): Promise<WikiPage> {
return apiRequest<WikiPage>('/api/wiki', {
method: 'POST',
token,
body: payload,
});
}
export async function updateWikiPage(
token: string,
pageId: string,
payload: {
title?: string;
content?: unknown;
parentId?: string | null;
sortOrder?: number;
isFolder?: boolean;
}
): Promise<WikiPage> {
return apiRequest<WikiPage>(`/api/wiki/${encodeURIComponent(pageId)}`, {
method: 'PATCH',
token,
body: payload,
});
}
export async function deleteWikiPage(token: string, pageId: string): Promise<{ success: boolean; deletedId?: string }> {
return apiRequest<{ success: boolean; deletedId?: string }>(`/api/wiki/${encodeURIComponent(pageId)}`, {
method: 'DELETE',
token,
});
}
export async function fetchProjectById(token: string, projectId: number): Promise<Project> {
return apiRequest<Project>(`/api/resource/projects/${projectId}`, { token });
}
function resolveProjectIdFromTask(task: Task): number | null {
const rawProject = task.project;
if (!rawProject) return null;
if (typeof rawProject === 'object') {
return rawProject.id ? Number(rawProject.id) : null;
}
return Number(rawProject);
}
export async function fetchProjectTasks(token: string, projectId: number): Promise<Task[]> {
const tasks = await fetchTasks(token);
return (tasks || []).filter((task) => resolveProjectIdFromTask(task) === Number(projectId));
}
export async function createProjectTask(
token: string,
payload: {
projectId: number;
name: string;
description?: string | null;
userId?: string | null;
categorie?: TaskStatus;
}
): Promise<Task> {
return createTask(token, {
name: payload.name,
description: payload.description || null,
userId: payload.userId || null,
categorie: payload.categorie || 'Offen',
project: payload.projectId,
});
}
function resolveProjectIdFromFile(file: ProjectFile): number | null {
const rawProject = file.project;
if (!rawProject) return null;
if (typeof rawProject === 'object') {
return rawProject.id ? Number(rawProject.id) : null;
}
return Number(rawProject);
}
function resolveCustomerIdFromFile(file: ProjectFile): number | null {
const rawCustomer = file.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
function resolvePlantIdFromFile(file: ProjectFile): number | null {
const rawPlant = file.plant;
if (!rawPlant) return null;
if (typeof rawPlant === 'object') {
return rawPlant.id ? Number(rawPlant.id) : null;
}
return Number(rawPlant);
}
function resolveCreatedDocumentIdFromFile(file: ProjectFile): number | null {
const rawCreatedDocument = file.createddocument;
if (!rawCreatedDocument) return null;
if (typeof rawCreatedDocument === 'object') {
return rawCreatedDocument.id ? Number(rawCreatedDocument.id) : null;
}
return Number(rawCreatedDocument);
}
function resolveCustomerIdFromCreatedDocument(doc: CreatedDocument): number | null {
const rawCustomer = doc.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
export async function fetchProjectFiles(token: string, projectId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const projectFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveProjectIdFromFile(file) === Number(projectId);
});
if (projectFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: projectFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchCustomerFiles(token: string, customerId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const customerFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveCustomerIdFromFile(file) === Number(customerId);
});
if (customerFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: customerFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchPlantFiles(token: string, plantId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const plantFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolvePlantIdFromFile(file) === Number(plantId);
});
if (plantFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: plantFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchCustomerCreatedDocuments(
token: string,
customerId: number
): Promise<CreatedDocument[]> {
const docs = await apiRequest<CreatedDocument[]>('/api/resource/createddocuments', { token });
return (docs || [])
.filter((doc) => !doc.archived && resolveCustomerIdFromCreatedDocument(doc) === Number(customerId))
.sort((a, b) => {
const dateA = new Date(String(a.documentDate || '')).getTime();
const dateB = new Date(String(b.documentDate || '')).getTime();
return dateB - dateA;
});
}
export async function fetchCreatedDocumentFiles(
token: string,
createdDocumentId: number
): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const createdDocumentFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveCreatedDocumentIdFromFile(file) === Number(createdDocumentId);
});
if (createdDocumentFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: createdDocumentFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function uploadProjectFile(
token: string,
payload: {
projectId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
project: payload.projectId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}
export async function uploadCustomerFile(
token: string,
payload: {
customerId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
customer: payload.customerId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}
export async function uploadPlantFile(
token: string,
payload: {
plantId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
plant: payload.plantId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
export type AuthUser = MeResponse['user'];
@@ -82,6 +83,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
async function bootstrap() {
await hydrateApiBaseUrl();
const storedToken = await getStoredToken();
if (!storedToken) {